Merge feature/settings-validation (server validation, cache/TTS scaffold) into feature/ui-polish

This commit is contained in:
Atte149 2026-06-16 11:40:52 +03:00
parent 23298b0f26
commit ed3afc1f16
18 changed files with 896 additions and 24 deletions

View file

@ -8,6 +8,7 @@ package org.dueattendant149.bookshelf.data.di
import android.app.Application import android.app.Application
import androidx.room.Room import androidx.room.Room
import androidx.work.WorkManager
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -46,6 +47,10 @@ object AppModule {
.build() .build()
} }
@Provides
@Singleton
fun provideWorkManager(app: Application): WorkManager = WorkManager.getInstance(app)
@Provides @Provides
@Singleton @Singleton
fun provideBookDatabase(app: Application): BookDatabase { fun provideBookDatabase(app: Application): BookDatabase {

View file

@ -29,6 +29,7 @@ import org.dueattendant149.bookshelf.data.parser.file.FileParserImpl
import org.dueattendant149.bookshelf.data.parser.text.TextParser import org.dueattendant149.bookshelf.data.parser.text.TextParser
import org.dueattendant149.bookshelf.data.parser.text.TextParserImpl import org.dueattendant149.bookshelf.data.parser.text.TextParserImpl
import org.dueattendant149.bookshelf.data.repository.BookRepositoryImpl import org.dueattendant149.bookshelf.data.repository.BookRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.CacheRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.CategoryRepositoryImpl import org.dueattendant149.bookshelf.data.repository.CategoryRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.ColorPresetRepositoryImpl import org.dueattendant149.bookshelf.data.repository.ColorPresetRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.FileSystemRepositoryImpl import org.dueattendant149.bookshelf.data.repository.FileSystemRepositoryImpl

View file

@ -12,16 +12,26 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.Update import androidx.room.Update
import kotlinx.coroutines.flow.Flow
import org.dueattendant149.bookshelf.data.local.dto.CachedFileEntity import org.dueattendant149.bookshelf.data.local.dto.CachedFileEntity
@Dao @Dao
interface CachedFileDao { interface CachedFileDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(file: CachedFileEntity) suspend fun insert(file: CachedFileEntity): Long
@Query("SELECT * FROM cached_file WHERE bookId = :bookId AND type = :type") @Query("SELECT * FROM cached_file WHERE bookId = :bookId AND type = :type")
suspend fun getByBookIdAndType(bookId: Int, type: String): List<CachedFileEntity> suspend fun getByBookIdAndType(bookId: Int, type: String): List<CachedFileEntity>
@Query("SELECT * FROM cached_file WHERE bookId = :bookId AND type = :type LIMIT 1")
suspend fun getByBookIdAndTypeSingle(bookId: Int, type: String): CachedFileEntity?
@Query("SELECT * FROM cached_file WHERE bookId = :bookId")
suspend fun getByBookId(bookId: Int): List<CachedFileEntity>
@Query("SELECT * FROM cached_file WHERE bookId = :bookId")
fun observeByBookId(bookId: Int): Flow<List<CachedFileEntity>>
@Query("SELECT * FROM cached_file WHERE status = :status") @Query("SELECT * FROM cached_file WHERE status = :status")
suspend fun getByStatus(status: String): List<CachedFileEntity> suspend fun getByStatus(status: String): List<CachedFileEntity>

View file

@ -31,6 +31,10 @@ import retrofit2.http.Streaming
*/ */
interface BookshelfApiService { interface BookshelfApiService {
// Health
@GET("health")
suspend fun health(): Response<String>
// Libraries / books // Libraries / books
@GET("api/v1/books/libraries") @GET("api/v1/books/libraries")
suspend fun getLibraries(): Response<List<LibraryResponse>> suspend fun getLibraries(): Response<List<LibraryResponse>>

View file

@ -0,0 +1,231 @@
/*
* 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.bookshelf.data.repository
import android.app.Application
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.dueattendant149.bookshelf.data.local.dto.BookEntity
import org.dueattendant149.bookshelf.data.local.dto.CachedFileEntity
import org.dueattendant149.bookshelf.data.local.room.BookDatabase
import org.dueattendant149.bookshelf.data.mapper.book.BookMapper
import org.dueattendant149.bookshelf.data.settings.ServerSettings
import org.dueattendant149.bookshelf.data.worker.CacheDownloadWorker
import org.dueattendant149.bookshelf.domain.model.cache.CacheState
import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus
import org.dueattendant149.bookshelf.domain.model.cache.CachedFile
import org.dueattendant149.bookshelf.domain.model.library.Book
import org.dueattendant149.bookshelf.domain.repository.CacheRepository
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class CacheRepositoryImpl
@Inject
constructor(
private val database: BookDatabase,
private val bookMapper: BookMapper,
private val serverSettings: ServerSettings,
private val workManager: WorkManager,
private val application: Application,
) : CacheRepository {
override suspend fun cacheBook(book: Book): Result<Unit> = runCatching {
withContext(Dispatchers.IO) {
val localBook = findOrInsertBook(book)
val bookId = localBook.id
val absUrl = serverSettings.getAbsUrl() ?: error("Audiobookshelf URL not configured")
if (book.coverUrl.isNotBlank()) {
createCacheRecord(bookId, "cover", book.coverUrl)
}
if (book.hasEbook) {
createCacheRecord(
bookId,
"ebook",
"$absUrl/api/items/${book.remoteId}/download",
)
}
if (book.hasAudio) {
createCacheRecord(
bookId,
"audio",
"$absUrl/api/items/${book.remoteId}/download",
)
}
val inputData =
Data
.Builder()
.putInt(CacheDownloadWorker.KEY_BOOK_ID, bookId)
.putString(CacheDownloadWorker.KEY_REMOTE_ID, book.remoteId)
.putString(CacheDownloadWorker.KEY_TITLE, book.title)
.putString(CacheDownloadWorker.KEY_MEDIA_TYPE, book.mediaType)
.putBoolean(CacheDownloadWorker.KEY_HAS_AUDIO, book.hasAudio)
.putBoolean(CacheDownloadWorker.KEY_HAS_EBOOK, book.hasEbook)
.putString(CacheDownloadWorker.KEY_COVER_URL, book.coverUrl)
.build()
val request =
OneTimeWorkRequestBuilder<CacheDownloadWorker>()
.setInputData(inputData)
.setConstraints(
Constraints
.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build(),
)
.build()
workManager.enqueueUniqueWork(
CacheDownloadWorker.workName(bookId),
ExistingWorkPolicy.KEEP,
request,
)
}
}
override suspend fun deleteCache(remoteId: String): Result<Unit> = runCatching {
withContext(Dispatchers.IO) {
val book = database.bookDao.findBookByRemoteId(remoteId) ?: return@withContext
val records = database.cachedFileDao.getByBookId(book.id)
records.forEach { record ->
record.localPath?.let { path ->
File(path).delete()
}
database.cachedFileDao.delete(record)
}
workManager.cancelUniqueWork(CacheDownloadWorker.workName(book.id))
}
}
override fun observeCacheStatus(remoteId: String): Flow<CacheStatus> =
flow {
val bookId = database.bookDao.findBookByRemoteId(remoteId)?.id
if (bookId == null) {
emit(
CacheStatus(
remoteId = remoteId,
state = CacheState.NONE,
progress = 0f,
cachedFiles = emptyList(),
),
)
return@flow
}
database.cachedFileDao.observeByBookId(bookId).map { records ->
records.toCacheStatus(remoteId)
}.collect { emit(it) }
}.flowOn(Dispatchers.IO)
private suspend fun findOrInsertBook(book: Book): BookEntity {
val existing = database.bookDao.findBookByRemoteId(book.remoteId)
return if (existing != null) {
val updated =
existing.copy(
title = book.title,
author = book.author.getAsString() ?: "",
coverUrl = book.coverUrl,
hasAudio = book.hasAudio,
hasEbook = book.hasEbook,
audioDuration = book.audioDuration,
mediaType = book.mediaType,
libraryId = book.libraryId,
lastSyncedAt = System.currentTimeMillis(),
)
database.bookDao.updateBook(updated)
database.bookDao.findBookByRemoteId(book.remoteId) ?: updated
} else {
val entity = bookMapper.toBookEntity(book.copy(filePath = ""))
database.bookDao.insertBook(entity)
database.bookDao.findBookByRemoteId(book.remoteId)
?: error("Failed to insert book for caching")
}
}
private suspend fun createCacheRecord(
bookId: Int,
type: String,
url: String,
) {
val existing = database.cachedFileDao.getByBookIdAndTypeSingle(bookId, type)
if (existing == null) {
database.cachedFileDao.insert(
CachedFileEntity(
bookId = bookId,
type = type,
remoteUrl = url,
status = "pending",
progress = 0f,
),
)
} else if (existing.status == "completed" &&
existing.localPath != null &&
File(existing.localPath).exists()
) {
// Already cached locally, leave it alone.
} else {
database.cachedFileDao.update(
existing.copy(status = "pending", progress = 0f),
)
}
}
private fun List<CachedFileEntity>.toCacheStatus(remoteId: String): CacheStatus {
if (isEmpty()) {
return CacheStatus(
remoteId = remoteId,
state = CacheState.NONE,
progress = 0f,
cachedFiles = emptyList(),
)
}
val files =
map {
CachedFile(
type = it.type,
status = it.status,
progress = it.progress,
localPath = it.localPath,
)
}
val state =
when {
files.any { it.status == "failed" } -> CacheState.FAILED
files.any { it.status == "downloading" } -> CacheState.DOWNLOADING
files.all { it.status == "completed" } -> CacheState.COMPLETED
else -> CacheState.PENDING
}
val progress =
when (state) {
CacheState.DOWNLOADING -> files.filter { it.status == "downloading" }.map { it.progress }.average().toFloat()
CacheState.COMPLETED -> 1f
else -> 0f
}
return CacheStatus(
remoteId = remoteId,
state = state,
progress = progress,
cachedFiles = files,
)
}
}

View file

@ -8,6 +8,8 @@ package org.dueattendant149.bookshelf.data.settings
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import org.dueattendant149.bookshelf.data.local.data_store.DataStore import org.dueattendant149.bookshelf.data.local.data_store.DataStore
import org.dueattendant149.bookshelf.domain.util.isValidUri
import org.dueattendant149.bookshelf.domain.util.normalizeUri
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -21,23 +23,63 @@ class ServerSettings
private val absUrlKey = stringPreferencesKey("abs_url") private val absUrlKey = stringPreferencesKey("abs_url")
private val absTokenKey = stringPreferencesKey("abs_token") private val absTokenKey = stringPreferencesKey("abs_token")
suspend fun getBookshelfUrl(): String? = dataStore.getNullableData(bookshelfUrlKey) suspend fun getBookshelfUrl(): String? =
suspend fun setBookshelfUrl(value: String) = dataStore.putData(bookshelfUrlKey, value) dataStore.getNullableData(bookshelfUrlKey)?.normalizeIfValid()
suspend fun getAbsUrl(): String? = dataStore.getNullableData(absUrlKey) suspend fun setBookshelfUrl(value: String) {
suspend fun setAbsUrl(value: String) = dataStore.putData(absUrlKey, value) dataStore.putData(bookshelfUrlKey, value.normalizeIfValid() ?: value.trim())
}
suspend fun getAbsToken(): String? = dataStore.getNullableData(absTokenKey) suspend fun getAbsUrl(): String? =
suspend fun setAbsToken(value: String) = dataStore.putData(absTokenKey, value) dataStore.getNullableData(absUrlKey)?.normalizeIfValid()
suspend fun hasCredentials(): Boolean = suspend fun setAbsUrl(value: String) {
!getBookshelfUrl().isNullOrBlank() && dataStore.putData(absUrlKey, value.normalizeIfValid() ?: value.trim())
!getAbsUrl().isNullOrBlank() && }
!getAbsToken().isNullOrBlank()
suspend fun getAbsToken(): String? = dataStore.getNullableData(absTokenKey)?.trim()
suspend fun setAbsToken(value: String) = dataStore.putData(absTokenKey, value.trim())
suspend fun hasCredentials(): Boolean = getMissingCredentialsError() == null
/**
* Returns a user-facing error message if any required credential is missing or invalid,
* or null when all credentials are present.
*/
suspend fun getMissingCredentialsError(): String? {
val bookshelfUrl = dataStore.getNullableData(bookshelfUrlKey)
if (bookshelfUrl.isNullOrBlank()) {
return "Bookshelf API URL is missing"
}
if (!bookshelfUrl.isValidUri()) {
return "Bookshelf API URL is invalid: $bookshelfUrl"
}
if (dataStore.getNullableData(absUrlKey).isNullOrBlank()) {
return "Audiobookshelf URL is missing"
}
if (dataStore.getNullableData(absTokenKey).isNullOrBlank()) {
return "ABS token is missing"
}
return null
}
suspend fun validateBookshelfUrl(): Result<String> {
val url = dataStore.getNullableData(bookshelfUrlKey)?.trim()
return when {
url.isNullOrBlank() -> Result.failure(IllegalArgumentException("Bookshelf URL is empty"))
!url.isValidUri() -> Result.failure(IllegalArgumentException("Bookshelf URL is invalid: $url"))
else -> Result.success(url.normalizeUri())
}
}
suspend fun clear() { suspend fun clear() {
setBookshelfUrl("") setBookshelfUrl("")
setAbsUrl("") setAbsUrl("")
setAbsToken("") setAbsToken("")
} }
private fun String.normalizeIfValid(): String? {
val trimmed = trim()
return if (trimmed.isValidUri()) trimmed.normalizeUri() else null
}
} }

View file

@ -0,0 +1,194 @@
/*
* 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.bookshelf.data.worker
import android.content.Context
import android.util.Log
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import okhttp3.ResponseBody
import org.dueattendant149.bookshelf.data.local.dto.CachedFileEntity
import org.dueattendant149.bookshelf.data.local.room.BookDatabase
import org.dueattendant149.bookshelf.data.remote.audiobookshelf.AudiobookshelfApiClientFactory
import org.dueattendant149.bookshelf.data.settings.ServerSettings
import retrofit2.Response
import java.io.File
import java.io.IOException
class CacheDownloadWorker(
context: Context,
params: WorkerParameters,
) : CoroutineWorker(context, params) {
companion object {
const val KEY_BOOK_ID = "book_id"
const val KEY_REMOTE_ID = "remote_id"
const val KEY_TITLE = "title"
const val KEY_MEDIA_TYPE = "media_type"
const val KEY_HAS_AUDIO = "has_audio"
const val KEY_HAS_EBOOK = "has_ebook"
const val KEY_COVER_URL = "cover_url"
fun workName(bookId: Int) = "cache-download-$bookId"
}
private val entryPoint: CacheWorkerEntryPoint by lazy {
EntryPointAccessors.fromApplication(applicationContext, CacheWorkerEntryPoint::class.java)
}
override suspend fun doWork(): Result {
val bookId = inputData.getInt(KEY_BOOK_ID, -1)
val remoteId = inputData.getString(KEY_REMOTE_ID) ?: return Result.failure()
val title = inputData.getString(KEY_TITLE) ?: remoteId
val hasAudio = inputData.getBoolean(KEY_HAS_AUDIO, false)
val hasEbook = inputData.getBoolean(KEY_HAS_EBOOK, false)
val coverUrl = inputData.getString(KEY_COVER_URL) ?: ""
if (bookId < 0) {
return Result.failure()
}
val database = entryPoint.database()
val serverSettings = entryPoint.serverSettings()
val clientFactory = entryPoint.audiobookshelfFactory()
val absUrl = serverSettings.getAbsUrl()
val token = serverSettings.getAbsToken()
if (absUrl.isNullOrBlank() || token.isNullOrBlank()) {
Log.e(TAG, "ABS credentials not configured")
return Result.failure()
}
val client = clientFactory.provideClient(absUrl, token)
if (client == null) {
Log.e(TAG, "Failed to create ABS client")
return Result.failure()
}
val bookDir = File(applicationContext.filesDir, "bookshelf-cache/$bookId").apply { mkdirs() }
return try {
if (coverUrl.isNotBlank()) {
downloadFile(
database = database,
bookId = bookId,
type = "cover",
remoteUrl = coverUrl,
localFile = File(bookDir, "cover"),
) { client.getCover(remoteId) }
}
if (hasEbook) {
downloadFile(
database = database,
bookId = bookId,
type = "ebook",
remoteUrl = "$absUrl/api/items/$remoteId/download",
localFile = File(bookDir, "ebook"),
) { client.downloadBook(remoteId) }
}
if (hasAudio) {
downloadFile(
database = database,
bookId = bookId,
type = "audio",
remoteUrl = "$absUrl/api/items/$remoteId/download",
localFile = File(bookDir, "audio"),
) { client.downloadBook(remoteId) }
}
Result.success()
} catch (e: Exception) {
Log.e(TAG, "Cache download failed for book $bookId ($title)", e)
Result.retry()
}
}
private suspend fun downloadFile(
database: BookDatabase,
bookId: Int,
type: String,
remoteUrl: String,
localFile: File,
download: suspend () -> Response<ResponseBody>,
) {
val record = getOrCreateRecord(database, bookId, type, remoteUrl)
if (record.status == "completed" && record.localPath != null && File(record.localPath).exists()) {
return
}
database.cachedFileDao.update(record.copy(status = "downloading", progress = 0f))
try {
val response = download()
if (!response.isSuccessful) {
throw IOException("Download failed: ${response.code()}")
}
val body = response.body() ?: throw IOException("Empty response body")
val totalBytes = body.contentLength().takeIf { it > 0 } ?: -1L
body.byteStream().use { input ->
localFile.outputStream().use { output ->
val buffer = ByteArray(8192)
var read: Int
var totalRead = 0L
while (input.read(buffer).also { read = it } != -1) {
output.write(buffer, 0, read)
totalRead += read
if (totalBytes > 0) {
val progress = totalRead / totalBytes.toFloat()
database.cachedFileDao.update(
record.copy(status = "downloading", progress = progress),
)
}
}
}
}
database.cachedFileDao.update(
record.copy(
status = "completed",
progress = 1f,
localPath = localFile.absolutePath,
),
)
} catch (e: Exception) {
database.cachedFileDao.update(record.copy(status = "failed", progress = 0f))
throw e
}
}
private suspend fun getOrCreateRecord(
database: BookDatabase,
bookId: Int,
type: String,
remoteUrl: String,
): CachedFileEntity {
return database.cachedFileDao.getByBookIdAndTypeSingle(bookId, type)
?: CachedFileEntity(
bookId = bookId,
type = type,
remoteUrl = remoteUrl,
).also { database.cachedFileDao.insert(it) }
}
@EntryPoint
@InstallIn(SingletonComponent::class)
interface CacheWorkerEntryPoint {
fun database(): BookDatabase
fun serverSettings(): ServerSettings
fun audiobookshelfFactory(): AudiobookshelfApiClientFactory
}
}
private const val TAG = "CacheDownloadWorker"

View file

@ -0,0 +1,29 @@
/*
* 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.bookshelf.domain.model.cache
data class CacheStatus(
val remoteId: String,
val state: CacheState,
val progress: Float,
val cachedFiles: List<CachedFile>,
)
enum class CacheState {
NONE,
PENDING,
DOWNLOADING,
COMPLETED,
FAILED,
}
data class CachedFile(
val type: String,
val status: String,
val progress: Float,
val localPath: String?,
)

View file

@ -0,0 +1,17 @@
/*
* 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.bookshelf.domain.repository
import kotlinx.coroutines.flow.Flow
import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus
import org.dueattendant149.bookshelf.domain.model.library.Book
interface CacheRepository {
suspend fun cacheBook(book: Book): Result<Unit>
suspend fun deleteCache(remoteId: String): Result<Unit>
fun observeCacheStatus(remoteId: String): Flow<CacheStatus>
}

View file

@ -0,0 +1,19 @@
/*
* 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.bookshelf.domain.use_case.cache
import org.dueattendant149.bookshelf.domain.model.library.Book
import org.dueattendant149.bookshelf.domain.repository.CacheRepository
import javax.inject.Inject
class CacheBookUseCase
@Inject
constructor(
private val repository: CacheRepository,
) {
suspend operator fun invoke(book: Book): Result<Unit> = repository.cacheBook(book)
}

View file

@ -0,0 +1,18 @@
/*
* 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.bookshelf.domain.use_case.cache
import org.dueattendant149.bookshelf.domain.repository.CacheRepository
import javax.inject.Inject
class DeleteCacheUseCase
@Inject
constructor(
private val repository: CacheRepository,
) {
suspend operator fun invoke(remoteId: String): Result<Unit> = repository.deleteCache(remoteId)
}

View file

@ -0,0 +1,20 @@
/*
* 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.bookshelf.domain.use_case.cache
import kotlinx.coroutines.flow.Flow
import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus
import org.dueattendant149.bookshelf.domain.repository.CacheRepository
import javax.inject.Inject
class GetCacheStatusUseCase
@Inject
constructor(
private val repository: CacheRepository,
) {
operator fun invoke(remoteId: String): Flow<CacheStatus> = repository.observeCacheStatus(remoteId)
}

View file

@ -0,0 +1,39 @@
/*
* 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.bookshelf.domain.use_case.remote
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.BookshelfApiClientFactory
import org.dueattendant149.bookshelf.domain.util.isValidUri
import org.dueattendant149.bookshelf.domain.util.normalizeUri
import javax.inject.Inject
class CheckHealthUseCase
@Inject
constructor(
private val clientFactory: BookshelfApiClientFactory,
) {
suspend operator fun invoke(url: String): Result<Unit> {
val trimmed = url.trim()
if (trimmed.isBlank()) {
return Result.failure(IllegalArgumentException("Bookshelf URL is empty"))
}
if (!trimmed.isValidUri()) {
return Result.failure(IllegalArgumentException("Bookshelf URL is invalid: $trimmed"))
}
val normalized = trimmed.normalizeUri()
val client = clientFactory.provideClient(normalized)
?: return Result.failure(IllegalStateException("Could not create API client for $normalized"))
return runCatching {
val response = client.health()
if (!response.isSuccessful) {
throw IllegalStateException("Server returned HTTP ${response.code()}")
}
}
}
}

View file

@ -1,16 +1,71 @@
package org.dueattendant149.bookshelf.domain.util package org.dueattendant149.bookshelf.domain.util
import android.net.Uri
private val URL_SCHEME_REGEX = Regex("^[hH][tT][tT][pP][sS]?://")
/** /**
* Ensures the URL ends with a trailing slash so Retrofit can append relative paths. * Returns true if the string is non-blank, has an http/https scheme,
* Also adds a default scheme if missing. * and can be parsed by [Uri.parse] into a host-bearing URI.
*/ */
fun String.fixUriScheme(): String { fun String.isValidUri(): Boolean {
var url = this.trim() val trimmed = trim()
if (!url.startsWith("http://") && !url.startsWith("https://")) { if (trimmed.isBlank()) return false
url = "https://$url" if (!trimmed.hasUriScheme()) return false
val uri = runCatching { Uri.parse(trimmed) }.getOrNull() ?: return false
return !uri.host.isNullOrBlank()
} }
if (!url.endsWith("/")) {
url = "$url/" /**
* Returns true if the string starts with http:// or https:// (case-insensitive).
*/
fun String.hasUriScheme(): Boolean = matches(URL_SCHEME_REGEX)
/**
* Adds https:// if the string has no http/https scheme.
*/
fun String.ensureUriScheme(): String {
val trimmed = trim()
return if (trimmed.hasUriScheme()) trimmed else "https://$trimmed"
} }
return url
/**
* 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/"
} }

View file

@ -9,13 +9,20 @@ package org.dueattendant149.bookshelf.presentation.bookshelf
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.dueattendant149.bookshelf.data.settings.ServerSettings import org.dueattendant149.bookshelf.data.settings.ServerSettings
import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus
import org.dueattendant149.bookshelf.domain.model.library.Book import org.dueattendant149.bookshelf.domain.model.library.Book
import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary
import org.dueattendant149.bookshelf.domain.use_case.cache.CacheBookUseCase
import org.dueattendant149.bookshelf.domain.use_case.cache.DeleteCacheUseCase
import org.dueattendant149.bookshelf.domain.use_case.cache.GetCacheStatusUseCase
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchBooksUseCase import org.dueattendant149.bookshelf.domain.use_case.remote.FetchBooksUseCase
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase
import javax.inject.Inject import javax.inject.Inject
@ -26,6 +33,8 @@ data class RemoteLibraryState(
val selectedLibrary: RemoteLibrary? = null, val selectedLibrary: RemoteLibrary? = null,
val isLoading: Boolean = false, val isLoading: Boolean = false,
val error: String? = null, val error: String? = null,
val cacheStatuses: Map<String, CacheStatus> = emptyMap(),
val offlineOnly: Boolean = false,
) )
@HiltViewModel @HiltViewModel
@ -34,10 +43,14 @@ class RemoteLibraryModel
constructor( constructor(
private val fetchLibrariesUseCase: FetchLibrariesUseCase, private val fetchLibrariesUseCase: FetchLibrariesUseCase,
private val fetchBooksUseCase: FetchBooksUseCase, private val fetchBooksUseCase: FetchBooksUseCase,
private val cacheBookUseCase: CacheBookUseCase,
private val deleteCacheUseCase: DeleteCacheUseCase,
private val getCacheStatusUseCase: GetCacheStatusUseCase,
private val serverSettings: ServerSettings, private val serverSettings: ServerSettings,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(RemoteLibraryState()) private val _state = MutableStateFlow(RemoteLibraryState())
val state = _state.asStateFlow() val state = _state.asStateFlow()
private var cacheStatusJob: Job? = null
init { init {
loadLibraries() loadLibraries()
@ -77,6 +90,56 @@ class RemoteLibraryModel
error = result.exceptionOrNull()?.message, error = result.exceptionOrNull()?.message,
) )
} }
observeCacheStatuses()
}
}
fun cacheBook(book: Book) {
viewModelScope.launch {
val result = cacheBookUseCase(book)
if (result.isFailure) {
_state.update {
it.copy(error = result.exceptionOrNull()?.message ?: "Cache failed")
}
}
observeCacheStatuses()
}
}
fun deleteCache(book: Book) {
viewModelScope.launch {
val result = deleteCacheUseCase(book.remoteId)
if (result.isFailure) {
_state.update {
it.copy(error = result.exceptionOrNull()?.message ?: "Delete cache failed")
}
}
}
}
fun toggleOfflineOnly() {
_state.update { it.copy(offlineOnly = !it.offlineOnly) }
}
private fun observeCacheStatuses() {
cacheStatusJob?.cancel()
val remoteIds = state.value.books.map { it.remoteId }.filter { it.isNotBlank() }
if (remoteIds.isEmpty()) {
_state.update { it.copy(cacheStatuses = emptyMap()) }
return
}
cacheStatusJob =
viewModelScope.launch {
combine(
remoteIds.map { remoteId ->
getCacheStatusUseCase(remoteId)
},
) { statuses ->
statuses.toList().associateBy { it.remoteId }
}.collect { statuses ->
_state.update { it.copy(cacheStatuses = statuses) }
}
} }
} }
} }

View file

@ -15,7 +15,9 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.dueattendant149.bookshelf.data.settings.ServerSettings import org.dueattendant149.bookshelf.data.settings.ServerSettings
import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary
import org.dueattendant149.bookshelf.domain.use_case.remote.CheckHealthUseCase
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase
import org.dueattendant149.bookshelf.domain.util.deriveAbsUrl
import javax.inject.Inject import javax.inject.Inject
data class BookshelfSettingsState( data class BookshelfSettingsState(
@ -24,6 +26,9 @@ data class BookshelfSettingsState(
val absToken: String = "", val absToken: String = "",
val libraries: List<RemoteLibrary> = emptyList(), val libraries: List<RemoteLibrary> = emptyList(),
val isLoading: Boolean = false, val isLoading: Boolean = false,
val isCheckingConnection: Boolean = false,
val connectionError: String? = null,
val connectionSuccess: Boolean = false,
val error: String? = null, val error: String? = null,
val librariesSuccess: Boolean = false, val librariesSuccess: Boolean = false,
) )
@ -34,6 +39,7 @@ class BookshelfSettingsModel
constructor( constructor(
private val serverSettings: ServerSettings, private val serverSettings: ServerSettings,
private val fetchLibrariesUseCase: FetchLibrariesUseCase, private val fetchLibrariesUseCase: FetchLibrariesUseCase,
private val checkHealthUseCase: CheckHealthUseCase,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(BookshelfSettingsState()) private val _state = MutableStateFlow(BookshelfSettingsState())
val state = _state.asStateFlow() val state = _state.asStateFlow()
@ -58,6 +64,8 @@ class BookshelfSettingsModel
it.copy( it.copy(
bookshelfUrl = value, bookshelfUrl = value,
librariesSuccess = false, librariesSuccess = false,
connectionSuccess = false,
connectionError = null,
error = null, error = null,
) )
} }
@ -66,6 +74,8 @@ class BookshelfSettingsModel
it.copy( it.copy(
absUrl = value, absUrl = value,
librariesSuccess = false, librariesSuccess = false,
connectionSuccess = false,
connectionError = null,
error = null, error = null,
) )
} }
@ -74,10 +84,27 @@ class BookshelfSettingsModel
it.copy( it.copy(
absToken = value, absToken = value,
librariesSuccess = false, librariesSuccess = false,
connectionSuccess = false,
connectionError = null,
error = null, error = null,
) )
} }
fun deriveAbsUrlFromBookshelf() {
viewModelScope.launch {
val derived = _state.value.bookshelfUrl.deriveAbsUrl()
if (derived != null) {
_state.update {
it.copy(
absUrl = derived,
librariesSuccess = false,
error = null,
)
}
}
}
}
fun save() { fun save() {
viewModelScope.launch { viewModelScope.launch {
val current = _state.value val current = _state.value
@ -87,6 +114,29 @@ class BookshelfSettingsModel
} }
} }
fun checkConnection() {
viewModelScope.launch {
_state.update {
it.copy(
isCheckingConnection = true,
connectionError = null,
connectionSuccess = false,
)
}
save()
val url = _state.value.bookshelfUrl
val result = checkHealthUseCase(url)
_state.update {
it.copy(
isCheckingConnection = false,
connectionSuccess = result.isSuccess,
connectionError = result.exceptionOrNull()?.message,
)
}
}
}
fun testLibraries() { fun testLibraries() {
viewModelScope.launch { viewModelScope.launch {
_state.update { _state.update {
@ -98,6 +148,18 @@ class BookshelfSettingsModel
} }
save() save()
val missingError = serverSettings.getMissingCredentialsError()
if (missingError != null) {
_state.update {
it.copy(
isLoading = false,
libraries = emptyList(),
error = missingError,
)
}
return@launch
}
val result = fetchLibrariesUseCase() val result = fetchLibrariesUseCase()
val libraries = result.getOrDefault(emptyList()) val libraries = result.getOrDefault(emptyList())
_state.update { _state.update {

View file

@ -104,14 +104,39 @@ fun BookshelfSettingsContent(
) { ) {
Button( Button(
onClick = model::save, onClick = model::save,
enabled = !state.isLoading, enabled = !state.isLoading && !state.isCheckingConnection,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) { ) {
Text(stringResource(R.string.save_button)) Text(stringResource(R.string.save_button))
} }
Button(
onClick = model::checkConnection,
enabled = !state.isLoading && !state.isCheckingConnection,
modifier = Modifier.weight(1f)
) {
if (state.isCheckingConnection) {
CircularProgressIndicator()
} else {
Text(stringResource(R.string.check_connection_button))
}
}
}
}
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(
onClick = model::deriveAbsUrlFromBookshelf,
enabled = state.bookshelfUrl.isNotBlank() && !state.isLoading,
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.derive_abs_url_button))
}
OutlinedButton( OutlinedButton(
onClick = model::testLibraries, onClick = model::testLibraries,
enabled = !state.isLoading, enabled = !state.isLoading && !state.isCheckingConnection,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) { ) {
if (state.isLoading) { if (state.isLoading) {
@ -122,6 +147,22 @@ fun BookshelfSettingsContent(
} }
} }
} }
state.connectionSuccess.takeIf { it }?.let {
item {
Text(
text = stringResource(R.string.connection_successful),
color = MaterialTheme.colorScheme.primary,
)
}
}
state.connectionError?.let { error ->
item {
Text(
text = error,
color = MaterialTheme.colorScheme.error,
)
}
}
state.librariesSuccess.takeIf { it }?.let { state.librariesSuccess.takeIf { it }?.let {
item { item {
Row( Row(

View file

@ -137,6 +137,18 @@
<string name="bookshelf_settings">Bookshelf</string> <string name="bookshelf_settings">Bookshelf</string>
<string name="bookshelf_settings_desc">Server, ABS, TTS</string> <string name="bookshelf_settings_desc">Server, ABS, TTS</string>
<!-- TTS -->
<string name="tts_screen">TTS</string>
<string name="tts_engine">Engine</string>
<string name="tts_voice">Voice</string>
<string name="tts_book_id">Book ID</string>
<string name="tts_speed">Speed</string>
<string name="tts_create_job">Create job</string>
<string name="tts_refresh_jobs">Refresh jobs</string>
<string name="tts_jobs">Jobs</string>
<string name="tts_jobs_button">TTS jobs</string>
<string name="tts_button">TTS</string>
<!-- Bookshelf server settings --> <!-- Bookshelf server settings -->
<string name="bookshelf_settings_screen_title">Bookshelf server</string> <string name="bookshelf_settings_screen_title">Bookshelf server</string>
<string name="bookshelf_url_option">Bookshelf API URL</string> <string name="bookshelf_url_option">Bookshelf API URL</string>
@ -146,7 +158,10 @@
<string name="abs_token_option">ABS token</string> <string name="abs_token_option">ABS token</string>
<string name="abs_token_option_desc">API token from Audiobookshelf settings</string> <string name="abs_token_option_desc">API token from Audiobookshelf settings</string>
<string name="save_button">Save</string> <string name="save_button">Save</string>
<string name="check_connection_button">Check connection</string>
<string name="derive_abs_url_button">Derive ABS URL</string>
<string name="fetch_libraries_button">Fetch libraries</string> <string name="fetch_libraries_button">Fetch libraries</string>
<string name="connection_successful">Connection successful</string>
<string name="libraries_fetched_successful">Connected to %1$d libraries</string> <string name="libraries_fetched_successful">Connected to %1$d libraries</string>
<string name="bookshelf_libraries_header">Libraries</string> <string name="bookshelf_libraries_header">Libraries</string>
<string name="bookshelf_library_subtitle">%1$s • %2$d items</string> <string name="bookshelf_library_subtitle">%1$s • %2$d items</string>
@ -510,6 +525,13 @@
<string name="browse_content_desc">Add books</string> <string name="browse_content_desc">Add books</string>
<string name="bookshelf_screen">Bookshelf</string> <string name="bookshelf_screen">Bookshelf</string>
<string name="bookshelf_content_desc">Remote bookshelf</string> <string name="bookshelf_content_desc">Remote bookshelf</string>
<string name="bookshelf_offline_only_label">Offline only</string>
<string name="bookshelf_cache_pending">Pending</string>
<string name="bookshelf_cache_downloading">Downloading</string>
<string name="bookshelf_cache_completed">Cached</string>
<string name="bookshelf_cache_failed">Failed</string>
<string name="bookshelf_download_action">Download</string>
<string name="bookshelf_delete_cache_action">Delete</string>
<string name="arrow_content_desc">Arrow</string> <string name="arrow_content_desc">Arrow</string>
<string name="reset_start_content_desc">Reset start guide</string> <string name="reset_start_content_desc">Reset start guide</string>
<string name="open_in_web_content_desc">Open in web</string> <string name="open_in_web_content_desc">Open in web</string>