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 androidx.room.Room
import androidx.work.WorkManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -46,6 +47,10 @@ object AppModule {
.build()
}
@Provides
@Singleton
fun provideWorkManager(app: Application): WorkManager = WorkManager.getInstance(app)
@Provides
@Singleton
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.TextParserImpl
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.ColorPresetRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.FileSystemRepositoryImpl

View file

@ -12,16 +12,26 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
import org.dueattendant149.bookshelf.data.local.dto.CachedFileEntity
@Dao
interface CachedFileDao {
@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")
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")
suspend fun getByStatus(status: String): List<CachedFileEntity>

View file

@ -31,6 +31,10 @@ import retrofit2.http.Streaming
*/
interface BookshelfApiService {
// Health
@GET("health")
suspend fun health(): Response<String>
// Libraries / books
@GET("api/v1/books/libraries")
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 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.Singleton
@ -21,23 +23,63 @@ class ServerSettings
private val absUrlKey = stringPreferencesKey("abs_url")
private val absTokenKey = stringPreferencesKey("abs_token")
suspend fun getBookshelfUrl(): String? = dataStore.getNullableData(bookshelfUrlKey)
suspend fun setBookshelfUrl(value: String) = dataStore.putData(bookshelfUrlKey, value)
suspend fun getBookshelfUrl(): String? =
dataStore.getNullableData(bookshelfUrlKey)?.normalizeIfValid()
suspend fun getAbsUrl(): String? = dataStore.getNullableData(absUrlKey)
suspend fun setAbsUrl(value: String) = dataStore.putData(absUrlKey, value)
suspend fun setBookshelfUrl(value: String) {
dataStore.putData(bookshelfUrlKey, value.normalizeIfValid() ?: value.trim())
}
suspend fun getAbsToken(): String? = dataStore.getNullableData(absTokenKey)
suspend fun setAbsToken(value: String) = dataStore.putData(absTokenKey, value)
suspend fun getAbsUrl(): String? =
dataStore.getNullableData(absUrlKey)?.normalizeIfValid()
suspend fun hasCredentials(): Boolean =
!getBookshelfUrl().isNullOrBlank() &&
!getAbsUrl().isNullOrBlank() &&
!getAbsToken().isNullOrBlank()
suspend fun setAbsUrl(value: String) {
dataStore.putData(absUrlKey, value.normalizeIfValid() ?: value.trim())
}
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() {
setBookshelfUrl("")
setAbsUrl("")
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
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.
* Also adds a default scheme if missing.
* 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.fixUriScheme(): String {
var url = this.trim()
if (!url.startsWith("http://") && !url.startsWith("https://")) {
url = "https://$url"
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()
}
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.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
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.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.FetchLibrariesUseCase
import javax.inject.Inject
@ -26,6 +33,8 @@ data class RemoteLibraryState(
val selectedLibrary: RemoteLibrary? = null,
val isLoading: Boolean = false,
val error: String? = null,
val cacheStatuses: Map<String, CacheStatus> = emptyMap(),
val offlineOnly: Boolean = false,
)
@HiltViewModel
@ -34,10 +43,14 @@ class RemoteLibraryModel
constructor(
private val fetchLibrariesUseCase: FetchLibrariesUseCase,
private val fetchBooksUseCase: FetchBooksUseCase,
private val cacheBookUseCase: CacheBookUseCase,
private val deleteCacheUseCase: DeleteCacheUseCase,
private val getCacheStatusUseCase: GetCacheStatusUseCase,
private val serverSettings: ServerSettings,
) : ViewModel() {
private val _state = MutableStateFlow(RemoteLibraryState())
val state = _state.asStateFlow()
private var cacheStatusJob: Job? = null
init {
loadLibraries()
@ -77,6 +90,56 @@ class RemoteLibraryModel
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 org.dueattendant149.bookshelf.data.settings.ServerSettings
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.util.deriveAbsUrl
import javax.inject.Inject
data class BookshelfSettingsState(
@ -24,6 +26,9 @@ data class BookshelfSettingsState(
val absToken: String = "",
val libraries: List<RemoteLibrary> = emptyList(),
val isLoading: Boolean = false,
val isCheckingConnection: Boolean = false,
val connectionError: String? = null,
val connectionSuccess: Boolean = false,
val error: String? = null,
val librariesSuccess: Boolean = false,
)
@ -34,6 +39,7 @@ class BookshelfSettingsModel
constructor(
private val serverSettings: ServerSettings,
private val fetchLibrariesUseCase: FetchLibrariesUseCase,
private val checkHealthUseCase: CheckHealthUseCase,
) : ViewModel() {
private val _state = MutableStateFlow(BookshelfSettingsState())
val state = _state.asStateFlow()
@ -58,6 +64,8 @@ class BookshelfSettingsModel
it.copy(
bookshelfUrl = value,
librariesSuccess = false,
connectionSuccess = false,
connectionError = null,
error = null,
)
}
@ -66,6 +74,8 @@ class BookshelfSettingsModel
it.copy(
absUrl = value,
librariesSuccess = false,
connectionSuccess = false,
connectionError = null,
error = null,
)
}
@ -74,10 +84,27 @@ class BookshelfSettingsModel
it.copy(
absToken = value,
librariesSuccess = false,
connectionSuccess = false,
connectionError = 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() {
viewModelScope.launch {
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() {
viewModelScope.launch {
_state.update {
@ -98,6 +148,18 @@ class BookshelfSettingsModel
}
save()
val missingError = serverSettings.getMissingCredentialsError()
if (missingError != null) {
_state.update {
it.copy(
isLoading = false,
libraries = emptyList(),
error = missingError,
)
}
return@launch
}
val result = fetchLibrariesUseCase()
val libraries = result.getOrDefault(emptyList())
_state.update {

View file

@ -104,14 +104,39 @@ fun BookshelfSettingsContent(
) {
Button(
onClick = model::save,
enabled = !state.isLoading,
enabled = !state.isLoading && !state.isCheckingConnection,
modifier = Modifier.weight(1f)
) {
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(
onClick = model::testLibraries,
enabled = !state.isLoading,
enabled = !state.isLoading && !state.isCheckingConnection,
modifier = Modifier.weight(1f)
) {
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 {
item {
Row(

View file

@ -137,6 +137,18 @@
<string name="bookshelf_settings">Bookshelf</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 -->
<string name="bookshelf_settings_screen_title">Bookshelf server</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_desc">API token from Audiobookshelf settings</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="connection_successful">Connection successful</string>
<string name="libraries_fetched_successful">Connected to %1$d libraries</string>
<string name="bookshelf_libraries_header">Libraries</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="bookshelf_screen">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="reset_start_content_desc">Reset start guide</string>
<string name="open_in_web_content_desc">Open in web</string>