Merge feature/settings-validation (server validation, cache/TTS scaffold) into feature/ui-polish
This commit is contained in:
parent
23298b0f26
commit
ed3afc1f16
18 changed files with 896 additions and 24 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
29
app/src/main/java/org/dueattendant149/bookshelf/domain/model/cache/CacheStatus.kt
vendored
Normal file
29
app/src/main/java/org/dueattendant149/bookshelf/domain/model/cache/CacheStatus.kt
vendored
Normal 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?,
|
||||
)
|
||||
|
|
@ -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>
|
||||
}
|
||||
19
app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/cache/CacheBookUseCase.kt
vendored
Normal file
19
app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/cache/CacheBookUseCase.kt
vendored
Normal 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)
|
||||
}
|
||||
18
app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/cache/DeleteCacheUseCase.kt
vendored
Normal file
18
app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/cache/DeleteCacheUseCase.kt
vendored
Normal 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)
|
||||
}
|
||||
20
app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/cache/GetCacheStatusUseCase.kt
vendored
Normal file
20
app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/cache/GetCacheStatusUseCase.kt
vendored
Normal 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)
|
||||
}
|
||||
|
|
@ -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()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
if (!url.endsWith("/")) {
|
||||
url = "$url/"
|
||||
}
|
||||
return 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()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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"
|
||||
}
|
||||
|
||||
/**
|
||||
* 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/"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue