chore: automated stabilization pass (5 review rounds, 51 fixes) (#468)

This commit is contained in:
Max Grakov
2026-07-19 17:01:08 +03:00
committed by GitHub
parent 616530c3a7
commit d421bc7cd8
84 changed files with 1410 additions and 806 deletions

View File

@@ -0,0 +1,437 @@
{
"formatVersion": 1,
"database": {
"version": 21,
"identityHash": "b5bacfcab06282fe9d1d6af28cb7e9ca",
"entities": [
{
"tableName": "detailed_books",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `subtitle` TEXT, `author` TEXT, `narrator` TEXT, `year` TEXT, `abstract` TEXT, `publisher` TEXT, `duration` INTEGER NOT NULL, `libraryId` TEXT, `seriesJson` TEXT, `seriesNames` TEXT, `seriesId` TEXT, `authorsJson` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "subtitle",
"columnName": "subtitle",
"affinity": "TEXT"
},
{
"fieldPath": "author",
"columnName": "author",
"affinity": "TEXT"
},
{
"fieldPath": "narrator",
"columnName": "narrator",
"affinity": "TEXT"
},
{
"fieldPath": "year",
"columnName": "year",
"affinity": "TEXT"
},
{
"fieldPath": "abstract",
"columnName": "abstract",
"affinity": "TEXT"
},
{
"fieldPath": "publisher",
"columnName": "publisher",
"affinity": "TEXT"
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "libraryId",
"columnName": "libraryId",
"affinity": "TEXT"
},
{
"fieldPath": "seriesJson",
"columnName": "seriesJson",
"affinity": "TEXT"
},
{
"fieldPath": "seriesNames",
"columnName": "seriesNames",
"affinity": "TEXT"
},
{
"fieldPath": "seriesId",
"columnName": "seriesId",
"affinity": "TEXT"
},
{
"fieldPath": "authorsJson",
"columnName": "authorsJson",
"affinity": "TEXT"
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_detailed_books_libraryId",
"unique": false,
"columnNames": [
"libraryId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_detailed_books_libraryId` ON `${TABLE_NAME}` (`libraryId`)"
},
{
"name": "index_detailed_books_seriesId",
"unique": false,
"columnNames": [
"seriesId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_detailed_books_seriesId` ON `${TABLE_NAME}` (`seriesId`)"
}
]
},
{
"tableName": "book_files",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookFileId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `duration` REAL NOT NULL, `mimeType` TEXT NOT NULL, `bookId` TEXT NOT NULL, FOREIGN KEY(`bookId`) REFERENCES `detailed_books`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bookFileId",
"columnName": "bookFileId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "size",
"columnName": "size",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "mimeType",
"columnName": "mimeType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bookId",
"columnName": "bookId",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_book_files_bookId",
"unique": false,
"columnNames": [
"bookId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_book_files_bookId` ON `${TABLE_NAME}` (`bookId`)"
}
],
"foreignKeys": [
{
"table": "detailed_books",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"bookId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "book_chapters",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookChapterId` TEXT NOT NULL, `duration` REAL NOT NULL, `start` REAL NOT NULL, `end` REAL NOT NULL, `title` TEXT NOT NULL, `bookId` TEXT NOT NULL, `isCached` INTEGER NOT NULL, FOREIGN KEY(`bookId`) REFERENCES `detailed_books`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bookChapterId",
"columnName": "bookChapterId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "start",
"columnName": "start",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "end",
"columnName": "end",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bookId",
"columnName": "bookId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isCached",
"columnName": "isCached",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_book_chapters_bookId",
"unique": false,
"columnNames": [
"bookId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_book_chapters_bookId` ON `${TABLE_NAME}` (`bookId`)"
}
],
"foreignKeys": [
{
"table": "detailed_books",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"bookId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "media_progress",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookId` TEXT NOT NULL, `currentTime` REAL NOT NULL, `isFinished` INTEGER NOT NULL, `lastUpdate` INTEGER NOT NULL, PRIMARY KEY(`bookId`))",
"fields": [
{
"fieldPath": "bookId",
"columnName": "bookId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "currentTime",
"columnName": "currentTime",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "isFinished",
"columnName": "isFinished",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastUpdate",
"columnName": "lastUpdate",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"bookId"
]
},
"indices": [
{
"name": "index_media_progress_bookId",
"unique": false,
"columnNames": [
"bookId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_media_progress_bookId` ON `${TABLE_NAME}` (`bookId`)"
}
]
},
{
"tableName": "libraries",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_bookmark",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `libraryItemId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `totalPosition` INTEGER NOT NULL, `syncState` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "libraryItemId",
"columnName": "libraryItemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "totalPosition",
"columnName": "totalPosition",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "syncState",
"columnName": "syncState",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_cached_bookmark_libraryItemId",
"unique": false,
"columnNames": [
"libraryItemId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_cached_bookmark_libraryItemId` ON `${TABLE_NAME}` (`libraryItemId`)"
},
{
"name": "index_cached_bookmark_createdAt",
"unique": false,
"columnNames": [
"createdAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_cached_bookmark_createdAt` ON `${TABLE_NAME}` (`createdAt`)"
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b5bacfcab06282fe9d1d6af28cb7e9ca')"
]
}
}

View File

@@ -94,7 +94,7 @@ class CachedBookGroupingTest {
insert("o1", "Other", "ser-other", """[{"title":"Other","sequence":"1","id":"ser-other"}]""")
insert("z1", "Zulu")
val all = repository.fetchLibraryGrouped(LIBRARY, pageSize = 20, pageNumber = 0)
val all = repository.fetchLibraryGrouped(LIBRARY, pageSize = 20, pageNumber = 0, libraryType = null)
assertEquals(4, all.totalItems)
assertEquals(listOf("a1", "ser-dune", "ser-other", "z1"), all.items.map { it.keyOf() })
@@ -103,10 +103,10 @@ class CachedBookGroupingTest {
assertEquals("Dune", dune.title)
assertEquals(listOf("d1", "d2", "d3"), dune.coverItemIds)
val page0 = repository.fetchLibraryGrouped(LIBRARY, pageSize = 2, pageNumber = 0)
val page0 = repository.fetchLibraryGrouped(LIBRARY, pageSize = 2, pageNumber = 0, libraryType = null)
assertEquals(listOf("a1", "ser-dune"), page0.items.map { it.keyOf() })
val page1 = repository.fetchLibraryGrouped(LIBRARY, pageSize = 2, pageNumber = 1)
val page1 = repository.fetchLibraryGrouped(LIBRARY, pageSize = 2, pageNumber = 1, libraryType = null)
assertEquals(listOf("ser-other", "z1"), page1.items.map { it.keyOf() })
}
@@ -117,7 +117,7 @@ class CachedBookGroupingTest {
insert("d2", "Dune B", "ser-dune")
insert("z1", "Zulu")
val books = repository.fetchSeriesItems(LIBRARY, "ser-dune")
val books = repository.fetchSeriesItems(LIBRARY, "ser-dune", libraryType = null)
assertEquals(setOf("d1", "d2"), books.map { it.id }.toSet())
}

View File

@@ -9,8 +9,8 @@ import io.mockk.mockk
import org.grakovne.lissen.channel.common.OperationError
import org.grakovne.lissen.channel.common.OperationResult
import org.grakovne.lissen.content.LissenMediaProvider
import org.grakovne.lissen.playback.service.apply
import org.grakovne.lissen.playback.service.unapply
import org.grakovne.lissen.playback.service.parseLissenUri
import org.grakovne.lissen.playback.service.toLissenUri
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
@@ -24,7 +24,7 @@ class LissenDataSourceFactoryIntegrationTest {
@Test
fun apply_creates_valid_lissen_uri() {
val uri = apply("book-123", "file-456")
val uri = toLissenUri("book-123", "file-456")
assertEquals("lissen", uri.scheme)
assertEquals(2, uri.pathSegments.size)
assertEquals("book-123", uri.pathSegments[0])
@@ -33,8 +33,8 @@ class LissenDataSourceFactoryIntegrationTest {
@Test
fun unapply_parses_valid_lissen_uri() {
val uri = apply("book-abc", "file-xyz")
val result = unapply(uri)
val uri = toLissenUri("book-abc", "file-xyz")
val result = parseLissenUri(uri)
assertNotNull(result)
assertEquals("book-abc", result!!.first)
assertEquals("file-xyz", result.second)
@@ -44,8 +44,8 @@ class LissenDataSourceFactoryIntegrationTest {
fun apply_unapply_roundtrip() {
val bookId = "li_abcdef123456"
val fileId = "ino_ghijkl789012"
val uri = apply(bookId, fileId)
val (parsedBookId, parsedFileId) = unapply(uri)!!
val uri = toLissenUri(bookId, fileId)
val (parsedBookId, parsedFileId) = parseLissenUri(uri)!!
assertEquals(bookId, parsedBookId)
assertEquals(fileId, parsedFileId)
}
@@ -53,38 +53,38 @@ class LissenDataSourceFactoryIntegrationTest {
@Test
fun unapply_returns_null_for_http_uri() {
val uri = Uri.parse("https://server/api/items/book1/file/file1")
assertNull(unapply(uri))
assertNull(parseLissenUri(uri))
}
@Test
fun unapply_returns_null_for_file_uri() {
val uri = Uri.parse("file:///data/media_cache/book1/file1")
assertNull(unapply(uri))
assertNull(parseLissenUri(uri))
}
@Test
fun unapply_returns_null_for_lissen_uri_with_single_segment() {
val uri = Uri.parse("lissen://book1")
assertNull(unapply(uri))
assertNull(parseLissenUri(uri))
}
@Test
fun unapply_returns_null_for_lissen_uri_with_three_segments() {
val uri = Uri.parse("lissen://host/book1/file1/extra")
val result = unapply(uri)
val result = parseLissenUri(uri)
// 3 segments → null
assertNull(result)
}
@Test
fun uri_resolution_returns_local_file_uri_when_cached() {
val lissenUri = apply("book-1", "file-1")
val lissenUri = toLissenUri("book-1", "file-1")
val localUri = Uri.parse("file:///data/media_cache/book-1/file-1")
every { mediaProvider.provideFileUri("book-1", "file-1") } returns
OperationResult.Success(localUri)
val (bookId, fileId) = unapply(lissenUri)!!
val (bookId, fileId) = parseLissenUri(lissenUri)!!
val resolved =
mediaProvider.provideFileUri(bookId, fileId).fold(
onSuccess = { it },
@@ -97,13 +97,13 @@ class LissenDataSourceFactoryIntegrationTest {
@Test
fun uri_resolution_returns_remote_uri_when_not_cached() {
val lissenUri = apply("book-1", "file-1")
val lissenUri = toLissenUri("book-1", "file-1")
val remoteUri = Uri.parse("https://server:8080/api/items/book-1/file/file-1")
every { mediaProvider.provideFileUri("book-1", "file-1") } returns
OperationResult.Success(remoteUri)
val (bookId, fileId) = unapply(lissenUri)!!
val (bookId, fileId) = parseLissenUri(lissenUri)!!
val resolved =
mediaProvider.provideFileUri(bookId, fileId).fold(
onSuccess = { it },
@@ -116,12 +116,12 @@ class LissenDataSourceFactoryIntegrationTest {
@Test
fun uri_resolution_falls_back_to_lissen_uri_on_error() {
val lissenUri = apply("book-1", "file-1")
val lissenUri = toLissenUri("book-1", "file-1")
every { mediaProvider.provideFileUri("book-1", "file-1") } returns
OperationResult.Error(OperationError.InternalError)
val (bookId, fileId) = unapply(lissenUri)!!
val (bookId, fileId) = parseLissenUri(lissenUri)!!
val resolved =
mediaProvider.provideFileUri(bookId, fileId).fold(
onSuccess = { it },
@@ -133,7 +133,7 @@ class LissenDataSourceFactoryIntegrationTest {
@Test
fun dataspec_preserves_position_after_uri_replacement() {
val lissenUri = apply("book-1", "file-1")
val lissenUri = toLissenUri("book-1", "file-1")
val localUri = Uri.parse("file:///data/media_cache/book-1/file-1")
val original =
@@ -155,7 +155,7 @@ class LissenDataSourceFactoryIntegrationTest {
@Test
fun dataspec_preserves_length_after_uri_replacement() {
val lissenUri = apply("book-1", "file-1")
val lissenUri = toLissenUri("book-1", "file-1")
val remoteUri = Uri.parse("https://server/api/items/book-1/file/file-1")
val original =
@@ -181,8 +181,8 @@ class LissenDataSourceFactoryIntegrationTest {
fun apply_handles_ids_with_underscores_and_dashes() {
val bookId = "li_abc-123_def"
val fileId = "ino_xyz-789_ghi"
val uri = apply(bookId, fileId)
val result = unapply(uri)
val uri = toLissenUri(bookId, fileId)
val result = parseLissenUri(uri)
assertNotNull(result)
assertEquals(bookId, result!!.first)
assertEquals(fileId, result.second)
@@ -190,8 +190,8 @@ class LissenDataSourceFactoryIntegrationTest {
@Test
fun apply_handles_numeric_ids() {
val uri = apply("123456", "789012")
val result = unapply(uri)
val uri = toLissenUri("123456", "789012")
val result = parseLissenUri(uri)
assertNotNull(result)
assertEquals("123456", result!!.first)
assertEquals("789012", result.second)

View File

@@ -13,7 +13,7 @@ import io.mockk.mockk
import org.grakovne.lissen.channel.common.OperationResult
import org.grakovne.lissen.content.LissenMediaProvider
import org.grakovne.lissen.playback.service.LocalFallbackDataSource
import org.grakovne.lissen.playback.service.apply
import org.grakovne.lissen.playback.service.toLissenUri
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
@@ -29,7 +29,7 @@ class LocalFallbackDataSourceTest {
private val mediaProvider = mockk<LissenMediaProvider>()
private val payload = ByteArray(1000) { (it % 251).toByte() }
private val lissenUri = apply("book-1", "file-1")
private val lissenUri = toLissenUri("book-1", "file-1")
private val remoteUri = Uri.parse("https://server:8080/api/items/book-1/file/file-1")
@Test

View File

@@ -58,7 +58,7 @@ class MediaLibraryTreeTest {
OperationResult.Error(OperationError.InternalError)
coEvery { lissenMediaProvider.fetchBooks(any(), any(), any()) } returns
OperationResult.Error(OperationError.InternalError)
coEvery { localCacheRepository.fetchDetailedItems() } returns
coEvery { localCacheRepository.fetchDetailedItems(any(), any()) } returns
OperationResult.Error(OperationError.InternalError)
tree = MediaLibraryTree(context, playbackPreferences, libraryPreferences, localCacheRepository, lissenMediaProvider)
@@ -231,7 +231,7 @@ class MediaLibraryTreeTest {
@Test
fun getChildren_downloads_returnsEmptyWhenRepositoryFails() =
runBlocking {
coEvery { localCacheRepository.fetchDetailedItems() } returns
coEvery { localCacheRepository.fetchDetailedItems(any(), any()) } returns
OperationResult.Error(OperationError.InternalError)
val result = tree.getChildren("root/downloads", 0, 100).get()
assertEquals(SessionResult.RESULT_SUCCESS, result.resultCode)
@@ -241,7 +241,7 @@ class MediaLibraryTreeTest {
@Test
fun getChildren_downloads_returnsDownloadedBooks() =
runBlocking {
coEvery { localCacheRepository.fetchDetailedItems() } returns
coEvery { localCacheRepository.fetchDetailedItems(any(), any()) } returns
OperationResult.Success(
PagedItems(
items =

View File

@@ -15,6 +15,8 @@ import org.grakovne.lissen.channel.audiobookshelf.common.converter.ConnectionInf
import org.grakovne.lissen.channel.audiobookshelf.common.converter.LibraryResponseConverter
import org.grakovne.lissen.channel.audiobookshelf.common.converter.PlaybackSessionResponseConverter
import org.grakovne.lissen.channel.audiobookshelf.common.converter.RecentListeningResponseConverter
import org.grakovne.lissen.channel.audiobookshelf.common.model.playback.DeviceInfo
import org.grakovne.lissen.channel.audiobookshelf.common.model.playback.PlaybackStartRequest
import org.grakovne.lissen.channel.common.ConnectionInfo
import org.grakovne.lissen.channel.common.MediaChannel
import org.grakovne.lissen.channel.common.OperationError
@@ -132,4 +134,21 @@ abstract class AudiobookshelfChannel(
.map { connectionInfoResponseConverter.apply(it) }
protected fun getClientName() = "Lissen App ${BuildConfig.VERSION_NAME}"
protected fun buildPlaybackStartRequest(
supportedMimeTypes: List<String>,
deviceId: String,
): PlaybackStartRequest =
PlaybackStartRequest(
supportedMimeTypes = supportedMimeTypes,
deviceInfo =
DeviceInfo(
clientName = getClientName(),
deviceId = deviceId,
deviceName = getClientName(),
),
forceTranscode = false,
forceDirectPlay = false,
mediaPlayer = getClientName(),
)
}

View File

@@ -7,6 +7,7 @@ import androidx.core.net.toUri
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.Call
@@ -52,6 +53,8 @@ class AudiobookshelfAuthService
private val contextCache: OAuthContextCache,
private val authMethodResponseConverter: AuthMethodResponseConverter,
) : ChannelAuthService(session) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override suspend fun authorize(
host: String,
username: String,
@@ -196,30 +199,38 @@ class AudiobookshelfAuthService
call: Call,
response: Response,
) {
Timber.d("OAuth redirect received from ABS: status=${response.code}")
response.use {
Timber.d("OAuth redirect received from ABS: status=${response.code}")
if (response.code != 302) {
onFailure(examineError(response.body.string()))
return
}
if (response.code != 302) {
val body =
try {
response.body.string()
} catch (ex: IOException) {
ex.message ?: ""
}
onFailure(examineError(body))
return
}
val location =
response
.header("Location")
?: kotlin.run {
onFailure(examineError("invalid_redirect"))
return
}
val location =
response
.header("Location")
?: kotlin.run {
onFailure(examineError("invalid_redirect"))
return
}
try {
val cookieHeaders: List<String> = response.headers("Set-Cookie")
contextCache.storeCookies(cookieHeaders)
try {
val cookieHeaders: List<String> = response.headers("Set-Cookie")
contextCache.storeCookies(cookieHeaders)
onSuccess()
forwardAuthRequest(location)
} catch (ex: Exception) {
Timber.e("Unable to process OAuth redirect due to: ${ex.message}")
onFailure(examineError(ex.message ?: ""))
onSuccess()
forwardAuthRequest(location)
} catch (ex: Exception) {
Timber.e("Unable to process OAuth redirect due to: ${ex.message}")
onFailure(examineError(ex.message ?: ""))
}
}
}
},
@@ -293,6 +304,7 @@ class AudiobookshelfAuthService
?.let { loginResponseConverter.apply(it) }
?: run {
Timber.e("OAuth token exchange returned an empty or unparseable user payload for $host (status=${response.code})")
onFailure("empty_user")
return
}
} catch (ex: Exception) {
@@ -301,7 +313,7 @@ class AudiobookshelfAuthService
return
}
CoroutineScope(Dispatchers.IO).launch { onSuccess(user) }
scope.launch { onSuccess(user) }
}
},
)

View File

@@ -49,6 +49,15 @@ class AudiobookshelfOAuthCallbackActivity : ComponentActivity() {
val code = data.getQueryParameter("code") ?: ""
Timber.d("Got Exchange code from ABS")
val returnedState = data.getQueryParameter("state").orEmpty()
val expectedState = contextCache.readPkce().state
if (expectedState.isEmpty() || returnedState != expectedState) {
Timber.e("OAuth state validation failed: returned state does not match the stored state")
onLoginFailed("invalid_state")
return
}
lifecycleScope.launch {
authService.exchangeToken(
host =

View File

@@ -18,8 +18,6 @@ import org.grakovne.lissen.channel.audiobookshelf.common.converter.LibraryPageRe
import org.grakovne.lissen.channel.audiobookshelf.common.converter.LibraryResponseConverter
import org.grakovne.lissen.channel.audiobookshelf.common.converter.PlaybackSessionResponseConverter
import org.grakovne.lissen.channel.audiobookshelf.common.converter.RecentListeningResponseConverter
import org.grakovne.lissen.channel.audiobookshelf.common.model.playback.DeviceInfo
import org.grakovne.lissen.channel.audiobookshelf.common.model.playback.PlaybackStartRequest
import org.grakovne.lissen.channel.audiobookshelf.library.converter.BookResponseConverter
import org.grakovne.lissen.channel.audiobookshelf.library.converter.LibraryFilteringRequestConverter
import org.grakovne.lissen.channel.audiobookshelf.library.converter.LibraryOrderingRequestConverter
@@ -183,8 +181,11 @@ class LibraryAudiobookshelfChannel
searchResult
.map { it.authors }
.map { authors -> authors.map { it.id } }
.map { ids -> ids.map { id -> async { dataRepository.fetchAuthorItems(id) } } }
.map { it.awaitAll() }
.map { ids ->
ids.map { id ->
async { concurrentFetchSemaphore.withPermit { dataRepository.fetchAuthorItems(id) } }
}
}.map { it.awaitAll() }
.map { result ->
result
.flatMap { authorResponse ->
@@ -264,27 +265,12 @@ class LibraryAudiobookshelfChannel
episodeId: String,
supportedMimeTypes: List<String>,
deviceId: String,
): OperationResult<PlaybackSession> {
val request =
PlaybackStartRequest(
supportedMimeTypes = supportedMimeTypes,
deviceInfo =
DeviceInfo(
clientName = getClientName(),
deviceId = deviceId,
deviceName = getClientName(),
),
forceTranscode = false,
forceDirectPlay = false,
mediaPlayer = getClientName(),
)
return dataRepository
): OperationResult<PlaybackSession> =
dataRepository
.startPlayback(
itemId = bookId,
request = request,
request = buildPlaybackStartRequest(supportedMimeTypes, deviceId),
).map { sessionResponseConverter.apply(it) }
}
override suspend fun fetchBook(bookId: String): OperationResult<DetailedItem> =
coroutineScope {

View File

@@ -12,8 +12,6 @@ import org.grakovne.lissen.channel.audiobookshelf.common.converter.ConnectionInf
import org.grakovne.lissen.channel.audiobookshelf.common.converter.LibraryResponseConverter
import org.grakovne.lissen.channel.audiobookshelf.common.converter.PlaybackSessionResponseConverter
import org.grakovne.lissen.channel.audiobookshelf.common.converter.RecentListeningResponseConverter
import org.grakovne.lissen.channel.audiobookshelf.common.model.playback.DeviceInfo
import org.grakovne.lissen.channel.audiobookshelf.common.model.playback.PlaybackStartRequest
import org.grakovne.lissen.channel.audiobookshelf.podcast.converter.PodcastOrderingRequestConverter
import org.grakovne.lissen.channel.audiobookshelf.podcast.converter.PodcastPageResponseConverter
import org.grakovne.lissen.channel.audiobookshelf.podcast.converter.PodcastResponseConverter
@@ -100,28 +98,13 @@ class PodcastAudiobookshelfChannel
episodeId: String,
supportedMimeTypes: List<String>,
deviceId: String,
): OperationResult<PlaybackSession> {
val request =
PlaybackStartRequest(
supportedMimeTypes = supportedMimeTypes,
deviceInfo =
DeviceInfo(
clientName = getClientName(),
deviceId = deviceId,
deviceName = getClientName(),
),
forceTranscode = false,
forceDirectPlay = false,
mediaPlayer = getClientName(),
)
return dataRepository
): OperationResult<PlaybackSession> =
dataRepository
.startPodcastPlayback(
itemId = bookId,
episodeId = episodeId,
request = request,
request = buildPlaybackStartRequest(supportedMimeTypes, deviceId),
).map { sessionResponseConverter.apply(it) }
}
override suspend fun fetchBook(bookId: String): OperationResult<DetailedItem> =
coroutineScope {

View File

@@ -21,7 +21,6 @@ import org.grakovne.lissen.domain.PlaybackProgress
import org.grakovne.lissen.domain.PlaybackSession
import org.grakovne.lissen.domain.RecentBook
import org.grakovne.lissen.domain.UserAccount
import org.grakovne.lissen.domain.isSame
import org.grakovne.lissen.persistence.preferences.LibraryPreferences
import timber.log.Timber
import java.io.File
@@ -60,14 +59,16 @@ class LissenMediaProvider
suspend fun provideBookmarks(playingItemId: String): List<Bookmark> =
cachedBookmarkProvider
.provideBookmarks(playingItemId)
.sortedByDescending { it.createdAt }
.fold(emptyList()) { acc, item -> if (acc.any { it.isSame(item) }) acc else acc + item }
.sortedDeduplicated()
suspend fun updateAndProvideBookmarks(playingItemId: String): List<Bookmark> =
cachedBookmarkProvider
.fetchBookmarks(playingItemId)
.sortedByDescending { it.createdAt }
.fold(emptyList()) { acc, b -> if (acc.any { it.isSame(b) }) acc else acc + b }
.sortedDeduplicated()
private fun List<Bookmark>.sortedDeduplicated(): List<Bookmark> =
sortedByDescending { it.createdAt }
.distinctBy { it.libraryItemId to it.totalPosition }
fun provideFileUri(
libraryItemId: String,
@@ -75,21 +76,20 @@ class LissenMediaProvider
): OperationResult<Uri> {
Timber.d("Resolving file URI: bookId=$libraryItemId, chapterId=$chapterId")
return when (preferences.isForceCache()) {
val cached =
localCacheRepository
.provideFileUri(libraryItemId, chapterId)
?.let { OperationResult.Success(it) }
return cached ?: when (preferences.isForceCache()) {
true -> {
localCacheRepository
.provideFileUri(libraryItemId, chapterId)
?.let { OperationResult.Success(it) }
?: OperationResult.Error(OperationError.InternalError)
OperationResult.Error(OperationError.InternalError)
}
false -> {
localCacheRepository
providePreferredChannel()
.provideFileUri(libraryItemId, chapterId)
?.let { OperationResult.Success(it) }
?: providePreferredChannel()
.provideFileUri(libraryItemId, chapterId)
.let { OperationResult.Success(it) }
.let { OperationResult.Success(it) }
}
}
}
@@ -156,7 +156,7 @@ class LissenMediaProvider
return when (preferences.isForceCache()) {
true -> {
localCacheRepository.searchBooks(libraryId = libraryId, query = query)
localCacheRepository.searchBooks(libraryId = libraryId, query = query, limit = limit)
}
false -> {
@@ -292,7 +292,7 @@ class LissenMediaProvider
false -> {
providePreferredChannel()
.fetchRecentListenedBooks(libraryId)
.map { items -> syncFromLocalProgress(libraryId = libraryId, detailedItems = items) }
.map { items -> mergeLocalRecentProgress(libraryId = libraryId, recentBooks = items) }
}
}
}
@@ -311,7 +311,7 @@ class LissenMediaProvider
false -> {
providePreferredChannel()
.fetchBook(bookId)
.map { syncFromLocalProgress(it) }
.map { mergeLocalItemProgress(it) }
.map { trimProgress(it) }
}
}
@@ -388,21 +388,21 @@ class LissenMediaProvider
)
}
private suspend fun syncFromLocalProgress(
private suspend fun mergeLocalRecentProgress(
libraryId: String,
detailedItems: List<RecentBook>,
recentBooks: List<RecentBook>,
): List<RecentBook> {
val localRecentlyBooks =
val localRecentBooks =
localCacheRepository
.fetchRecentListenedBooks(libraryId)
.fold(
onSuccess = { it },
onFailure = { return@fold detailedItems },
onFailure = { return@fold recentBooks },
)
val syncedRecentlyBooks =
detailedItems
.mapNotNull { item -> localRecentlyBooks.find { it.id == item.id }?.let { item to it } }
val syncedRecentBooks =
recentBooks
.mapNotNull { item -> localRecentBooks.find { it.id == item.id }?.let { item to it } }
.map { (remote, local) ->
val localTimestamp = local.listenedLastUpdate ?: return@map remote
val remoteTimestamp = remote.listenedLastUpdate ?: return@map remote
@@ -413,9 +413,9 @@ class LissenMediaProvider
}
}
return detailedItems
return recentBooks
.map { item ->
syncedRecentlyBooks
syncedRecentBooks
.find { item.id == it.id }
?.let { local -> item.copy(listenedPercentage = local.listenedPercentage) }
?: item
@@ -433,7 +433,7 @@ class LissenMediaProvider
}
}
private suspend fun syncFromLocalProgress(detailedItem: DetailedItem): DetailedItem {
private suspend fun mergeLocalItemProgress(detailedItem: DetailedItem): DetailedItem {
val cachedProgress = localCacheRepository.fetchPlayingItemProgress(detailedItem.id)
val channelProgress = detailedItem.progress

View File

@@ -0,0 +1,9 @@
package org.grakovne.lissen.content.cache.common
import java.security.MessageDigest
fun String.toFileKey(): String =
MessageDigest
.getInstance("SHA-256")
.digest(toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it) }

View File

@@ -27,8 +27,14 @@ class CachingSessionRegistry {
return true
}
fun settle(itemId: String) {
pending.remove(itemId)
fun settle(
itemId: String,
job: Job,
) {
if (jobs[itemId] === job) {
pending.remove(itemId)
jobs.remove(itemId)
}
}
fun updateStatus(

View File

@@ -13,6 +13,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import org.grakovne.lissen.common.NetworkService
import org.grakovne.lissen.common.NetworkTypeAutoCache
@@ -56,9 +57,11 @@ class ContentAutoCachingService
mediaRepository.playingBook,
mediaRepository.isPlaying,
mediaRepository.currentChapterIndex,
) { playingItem: DetailedItem?, isPlaying: Boolean, _: Int ->
playingItem to isPlaying
}.collectLatest { (playingItem, isPlaying) ->
) { playingItem: DetailedItem?, isPlaying: Boolean, chapterIndex: Int ->
Triple(playingItem, isPlaying, chapterIndex)
}.distinctUntilChanged { old, new ->
old.first?.id == new.first?.id && old.second == new.second && old.third == new.third
}.collectLatest { (playingItem, isPlaying, _) ->
delayedJob?.cancel()
delayedJob = updatePlaybackCache(playingItem, isPlaying)
}

View File

@@ -137,6 +137,8 @@ class ContentCachingManager
chapterId: String,
) = bookRepository.provideCacheState(mediaItemId, chapterId)
fun provideCachedChapterIds(mediaItemId: String) = bookRepository.provideCachedChapterIds(mediaItemId)
private suspend fun cacheBookMedia(
bookId: String,
files: List<BookFile>,
@@ -168,6 +170,7 @@ class ContentCachingManager
if (!response.isSuccessful) {
Timber.e("Unable to cache media content: $response")
response.close()
return@withContext CacheState(CacheStatus.Error)
}
@@ -221,14 +224,13 @@ class ContentCachingManager
.writeToFile(file)
} catch (ex: Exception) {
Timber.e("Unable to cache cover for ${book.id} due to: ${ex.message}")
return@fold CacheState(CacheStatus.Error)
}
CacheState(CacheStatus.Completed)
},
onFailure = {
CacheState(CacheStatus.Completed)
},
)
CacheState(CacheStatus.Completed)
}
}

View File

@@ -6,8 +6,10 @@ import android.os.Build
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.job
import kotlinx.coroutines.launch
import org.grakovne.lissen.content.LissenMediaProvider
import org.grakovne.lissen.content.cache.persistent.ContentCachingNotificationService.Companion.NOTIFICATION_ID
@@ -106,7 +108,7 @@ class ContentCachingService : LifecycleService() {
onSuccess = { item -> cacheFetchedItem(item, task) },
onFailure = {
Timber.e("Unable to fetch book ${task.itemId} for caching: ${it.code}")
registry.settle(task.itemId)
registry.settle(task.itemId, currentCoroutineContext().job)
cacheProgressBus.emit(task.itemId, CacheState(CacheStatus.Error))
if (registry.inProgress().not()) {
@@ -137,7 +139,7 @@ class ContentCachingService : LifecycleService() {
Timber.e(error, "Caching failed for ${item.id}, emitting error state")
emit(CacheState(CacheStatus.Error))
}.onCompletion {
registry.settle(item.id)
registry.settle(item.id, currentCoroutineContext().job)
if (registry.inProgress().not()) {
finish()
@@ -176,7 +178,7 @@ class ContentCachingService : LifecycleService() {
}
companion object {
const val CACHE_ITEM_ACTION = "CACHING_TASK_EXTRA"
const val CACHE_ITEM_ACTION = "org.grakovne.lissen.CACHE_ITEM_ACTION"
const val STOP_CACHING_ACTION = "STOP_CACHING_ACTION"
const val CACHING_TASK_EXTRA = "CACHING_TASK_EXTRA"

View File

@@ -49,6 +49,7 @@ object LocalCacheModule {
.addMigrations(MIGRATION_17_18)
.addMigrations(MIGRATION_18_19)
.addMigrations(MIGRATION_19_20)
.addMigrations(MIGRATION_20_21)
.build()
}

View File

@@ -72,24 +72,12 @@ class LocalCacheRepository
suspend fun searchBooks(
libraryId: String,
query: String,
limit: Int,
): OperationResult<List<Book>> =
cachedBookRepository
.searchBooks(libraryId = libraryId, query = query)
.searchBooks(libraryId = libraryId, query = query, limit = limit)
.let { OperationResult.Success(it) }
suspend fun fetchDetailedItems(): OperationResult<PagedItems<DetailedItem>> {
val items = cachedBookRepository.fetchCachedItems()
return OperationResult
.Success(
PagedItems(
items = items,
currentPage = 0,
totalItems = cachedBookRepository.countCachedItems(),
),
)
}
suspend fun fetchDetailedItems(
pageSize: Int,
pageNumber: Int,
@@ -113,16 +101,18 @@ class LocalCacheRepository
pageSize: Int,
pageNumber: Int,
): OperationResult<PagedItems<Book>> {
val libraryType = cachedLibraryRepository.fetchLibraryType(libraryId)
val books =
cachedBookRepository
.fetchBooks(pageNumber = pageNumber, pageSize = pageSize, libraryId = libraryId)
.fetchBooks(pageNumber = pageNumber, pageSize = pageSize, libraryId = libraryId, libraryType = libraryType)
return OperationResult
.Success(
PagedItems(
items = books,
currentPage = pageNumber,
totalItems = cachedBookRepository.countBooks(libraryId),
totalItems = cachedBookRepository.countBooks(libraryId, libraryType),
),
)
}
@@ -141,22 +131,22 @@ class LocalCacheRepository
LibraryGrouping.SERIES -> {
cachedBookRepository
.fetchLibraryGrouped(libraryId = libraryId, pageSize = pageSize, pageNumber = pageNumber)
.let { OperationResult.Success(it) }
.fetchLibraryGrouped(
libraryId = libraryId,
pageSize = pageSize,
pageNumber = pageNumber,
libraryType = cachedLibraryRepository.fetchLibraryType(libraryId),
).let { OperationResult.Success(it) }
}
LibraryGrouping.AUTHOR -> {
val entries = cachedBookRepository.fetchAuthorsGrouped(libraryId)
val fromIndex = (pageNumber * pageSize).coerceIn(0, entries.size)
val toIndex = (fromIndex + pageSize).coerceIn(0, entries.size)
OperationResult.Success(
PagedItems(
items = entries.subList(fromIndex, toIndex),
currentPage = pageNumber,
totalItems = entries.size,
),
)
cachedBookRepository
.fetchAuthorsGrouped(
libraryId = libraryId,
pageSize = pageSize,
pageNumber = pageNumber,
libraryType = cachedLibraryRepository.fetchLibraryType(libraryId),
).let { OperationResult.Success(it) }
}
}
@@ -165,16 +155,22 @@ class LocalCacheRepository
seriesId: String,
): OperationResult<List<Book>> =
cachedBookRepository
.fetchSeriesItems(libraryId = libraryId, seriesId = seriesId)
.let { OperationResult.Success(it) }
.fetchSeriesItems(
libraryId = libraryId,
seriesId = seriesId,
libraryType = cachedLibraryRepository.fetchLibraryType(libraryId),
).let { OperationResult.Success(it) }
suspend fun fetchAuthorItems(
libraryId: String,
authorId: String,
): OperationResult<List<Book>> =
cachedBookRepository
.fetchAuthorItems(libraryId = libraryId, authorId = authorId)
.let { OperationResult.Success(it) }
.fetchAuthorItems(
libraryId = libraryId,
authorId = authorId,
libraryType = cachedLibraryRepository.fetchLibraryType(libraryId),
).let { OperationResult.Success(it) }
suspend fun fetchLibraries(): OperationResult<List<Library>> =
cachedLibraryRepository

View File

@@ -21,7 +21,7 @@ import org.grakovne.lissen.content.cache.persistent.entity.MediaProgressEntity
CachedLibraryEntity::class,
CachedBookmarkEntity::class,
],
version = 20,
version = 21,
exportSchema = true,
)
abstract class LocalCacheStorage : RoomDatabase() {

View File

@@ -371,3 +371,11 @@ val MIGRATION_19_20 =
}
}
}
val MIGRATION_20_21 =
object : Migration(20, 21) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE INDEX IF NOT EXISTS index_detailed_books_libraryId ON detailed_books(libraryId)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_detailed_books_seriesId ON detailed_books(seriesId)")
}
}

View File

@@ -2,8 +2,8 @@ package org.grakovne.lissen.content.cache.persistent
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import org.grakovne.lissen.content.cache.common.toFileKey
import java.io.File
import java.security.MessageDigest
import javax.inject.Inject
import javax.inject.Singleton
@@ -29,19 +29,19 @@ class OfflineBookStorageProperties
}
}
fun provideBookCache(bookId: String): File = baseFolder().resolve(bookId)
fun provideBookCache(bookId: String): File = baseFolder().resolve(bookId.toFileKey())
fun provideMediaCachePatch(
bookId: String,
fileId: String,
): File =
baseFolder()
.resolve(bookId)
.resolve(fileId)
.resolve(bookId.toFileKey())
.resolve(fileId.toFileKey())
fun provideBookCoverPath(bookId: String): File =
baseFolder()
.resolve(bookId)
.resolve(bookId.toFileKey())
.resolve("cover.img")
fun provideAuthorImagePath(authorName: String): File =
@@ -49,12 +49,6 @@ class OfflineBookStorageProperties
.resolve(AUTHORS_FOLDER)
.resolve("${authorName.toFileKey()}.img")
private fun String.toFileKey(): String =
MessageDigest
.getInstance("SHA-256")
.digest(toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it) }
companion object {
const val MEDIA_CACHE_FOLDER = "media_cache"
private const val AUTHORS_FOLDER = "authors"

View File

@@ -4,11 +4,9 @@ import android.net.Uri
import androidx.core.net.toUri
import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery
import com.squareup.moshi.Types
import org.grakovne.lissen.common.LibraryOrderingDirection
import org.grakovne.lissen.common.LibraryOrderingOption
import org.grakovne.lissen.common.mergeAuthorNames
import org.grakovne.lissen.common.moshi
import org.grakovne.lissen.content.cache.persistent.OfflineBookStorageProperties
import org.grakovne.lissen.content.cache.persistent.converter.CachedBookEntityConverter
import org.grakovne.lissen.content.cache.persistent.converter.CachedBookEntityDetailedConverter
@@ -16,11 +14,11 @@ import org.grakovne.lissen.content.cache.persistent.converter.CachedBookEntityRe
import org.grakovne.lissen.content.cache.persistent.converter.MediaProgressEntityConverter
import org.grakovne.lissen.content.cache.persistent.dao.CachedBookDao
import org.grakovne.lissen.content.cache.persistent.entity.BookEntity
import org.grakovne.lissen.content.cache.persistent.entity.BookSeriesDto
import org.grakovne.lissen.content.cache.persistent.entity.MediaProgressEntity
import org.grakovne.lissen.domain.Book
import org.grakovne.lissen.domain.DetailedItem
import org.grakovne.lissen.domain.LibraryEntry
import org.grakovne.lissen.domain.LibraryType
import org.grakovne.lissen.domain.PagedItems
import org.grakovne.lissen.domain.PlaybackProgress
import org.grakovne.lissen.domain.PlayingChapter
@@ -31,6 +29,10 @@ import java.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
private const val FINISHED_POSITION_EPSILON = 1.0
private const val AUTHOR_KEY = "TRIM(CASE WHEN instr(author, ',') > 0 THEN substr(author, 1, instr(author, ',') - 1) ELSE author END)"
@Singleton
class CachedBookRepository
@Inject
@@ -79,6 +81,8 @@ class CachedBookRepository
chapterId: String,
) = bookDao.isBookChapterCached(bookId, chapterId)
fun provideCachedChapterIds(bookId: String) = bookDao.cachedChapterIds(bookId)
suspend fun fetchCachedItems() =
bookDao
.fetchCachedItems()
@@ -99,17 +103,16 @@ class CachedBookRepository
libraryId: String,
pageNumber: Int,
pageSize: Int,
libraryType: LibraryType?,
): List<Book> {
val (option, direction) = buildOrdering()
val request =
FetchRequestBuilder()
.libraryId(libraryId)
fetchRequest(libraryId, libraryType)
.pageNumber(pageNumber)
.pageSize(pageSize)
.orderField(option)
.orderDirection(direction)
.hideCompleted(preferences.getHideCompleted())
.build()
return bookDao
@@ -117,20 +120,36 @@ class CachedBookRepository
.map { cachedBookEntityConverter.apply(it) }
}
suspend fun countBooks(libraryId: String): Int = bookDao.countCachedBooks(libraryId = libraryId)
suspend fun countBooks(
libraryId: String,
libraryType: LibraryType?,
): Int = bookDao.countRaw(fetchRequest(libraryId, libraryType).buildCount())
private fun fetchRequest(
libraryId: String,
libraryType: LibraryType?,
): FetchRequestBuilder =
FetchRequestBuilder()
.libraryId(libraryId)
.libraryType(libraryType)
.hideCompleted(preferences.getHideCompleted())
private fun hideCompletedClauses(libraryType: LibraryType?): Pair<String, String> =
hideCompletedSql(hideCompletedApplies(preferences.getHideCompleted(), libraryType), "id")
suspend fun fetchLibraryGrouped(
libraryId: String,
pageSize: Int,
pageNumber: Int,
libraryType: LibraryType?,
): PagedItems<LibraryEntry> {
val total = bookDao.countGroupedEntries(libraryId)
val total = bookDao.countRaw(buildGroupedCountQuery(libraryId, libraryType))
if (total == 0) {
return PagedItems(items = emptyList(), currentPage = pageNumber, totalItems = 0)
}
val headers = bookDao.fetchGroupedEntries(buildGroupedPageQuery(libraryId, pageSize, pageNumber))
val headers = bookDao.fetchGroupedEntries(buildGroupedPageQuery(libraryId, pageSize, pageNumber, libraryType))
val standaloneBooks =
headers
@@ -175,34 +194,42 @@ class CachedBookRepository
suspend fun fetchSeriesItems(
libraryId: String,
seriesId: String,
): List<Book> =
bookDao
.fetchBooksBySeriesIds(listOf(seriesId))
libraryType: LibraryType?,
): List<Book> {
val (join, filter) = hideCompletedClauses(libraryType)
val sql =
"""
SELECT detailed_books.* FROM detailed_books
$join
WHERE seriesId = ? $filter
""".trimIndent()
return bookDao
.fetchCachedBooks(SimpleSQLiteQuery(sql, arrayOf<Any>(seriesId)))
.map { cachedBookEntityConverter.apply(it) }
}
private fun buildGroupedPageQuery(
libraryId: String,
pageSize: Int,
pageNumber: Int,
libraryType: LibraryType?,
): SupportSQLiteQuery {
val (option, direction) = buildOrdering()
val field =
when (option) {
"author" -> "author"
"duration" -> "duration"
else -> "title"
}
val descending = direction.equals("desc", ignoreCase = true)
val field = resolveOrderField(option)
val descending = resolveOrderDirection(direction) == "DESC"
val aggregate = if (descending) "MAX" else "MIN"
val sortDirection = if (descending) "DESC" else "ASC"
val (join, filter) = hideCompletedClauses(libraryType)
val sql =
"""
SELECT COALESCE(seriesId, id) AS groupKey, seriesId AS seriesId, COUNT(*) AS bookCount
FROM detailed_books
WHERE libraryId = ?
$join
WHERE libraryId = ? $filter
GROUP BY COALESCE(seriesId, id)
ORDER BY $aggregate($field) $sortDirection
LIMIT ? OFFSET ?
@@ -211,60 +238,116 @@ class CachedBookRepository
return SimpleSQLiteQuery(sql, arrayOf<Any>(libraryId, pageSize, pageNumber * pageSize))
}
suspend fun fetchAuthorsGrouped(libraryId: String): List<LibraryEntry> =
fetchAllEntities(libraryId)
.groupingBy { it.primaryAuthor() }
.eachCount()
.mapNotNull { (author, count) ->
author?.let { LibraryEntry.AuthorEntry(id = it, name = it, bookCount = count) }
}.sortedBy { it.name.lowercase() }
private fun buildGroupedCountQuery(
libraryId: String,
libraryType: LibraryType?,
): SupportSQLiteQuery {
val (join, filter) = hideCompletedClauses(libraryType)
val sql =
"""
SELECT COUNT(DISTINCT COALESCE(seriesId, id))
FROM detailed_books
$join
WHERE libraryId = ? $filter
""".trimIndent()
return SimpleSQLiteQuery(sql, arrayOf<Any>(libraryId))
}
suspend fun fetchAuthorsGrouped(
libraryId: String,
pageSize: Int,
pageNumber: Int,
libraryType: LibraryType?,
): PagedItems<LibraryEntry> {
val total = bookDao.countRaw(buildAuthorCountQuery(libraryId, libraryType))
if (total == 0) {
return PagedItems(items = emptyList(), currentPage = pageNumber, totalItems = 0)
}
val items =
bookDao
.fetchAuthorEntries(buildAuthorPageQuery(libraryId, pageSize, pageNumber, libraryType))
.map { LibraryEntry.AuthorEntry(id = it.author, name = it.author, bookCount = it.bookCount) }
return PagedItems(items = items, currentPage = pageNumber, totalItems = total)
}
suspend fun fetchAuthorItems(
libraryId: String,
authorId: String,
): List<Book> =
fetchAllEntities(libraryId)
.filter { it.primaryAuthor() == authorId }
.map { cachedBookEntityConverter.apply(it) }
private fun BookEntity.primaryAuthor(): String? =
author
?.substringBefore(",")
?.trim()
?.takeIf { it.isNotEmpty() }
private suspend fun fetchAllEntities(libraryId: String): List<BookEntity> {
libraryType: LibraryType?,
): List<Book> {
val (option, direction) = buildOrdering()
val total = countBooks(libraryId)
if (total == 0) {
return emptyList()
}
val field = resolveOrderField(option)
val sortDirection = resolveOrderDirection(direction)
val (join, filter) = hideCompletedClauses(libraryType)
val request =
FetchRequestBuilder()
.libraryId(libraryId)
.pageNumber(0)
.pageSize(total)
.orderField(option)
.orderDirection(direction)
.hideCompleted(preferences.getHideCompleted())
.build()
val sql =
"""
SELECT detailed_books.* FROM detailed_books
$join
WHERE libraryId = ? AND $AUTHOR_KEY = ? $filter
ORDER BY $field $sortDirection
""".trimIndent()
return bookDao.fetchCachedBooks(request)
return bookDao
.fetchCachedBooks(SimpleSQLiteQuery(sql, arrayOf<Any>(libraryId, authorId)))
.map { cachedBookEntityConverter.apply(it) }
}
private fun buildAuthorPageQuery(
libraryId: String,
pageSize: Int,
pageNumber: Int,
libraryType: LibraryType?,
): SupportSQLiteQuery {
val (join, filter) = hideCompletedClauses(libraryType)
val sql =
"""
SELECT $AUTHOR_KEY AS author, COUNT(*) AS bookCount
FROM detailed_books
$join
WHERE libraryId = ? AND $AUTHOR_KEY IS NOT NULL AND $AUTHOR_KEY != '' $filter
GROUP BY $AUTHOR_KEY
ORDER BY LOWER($AUTHOR_KEY) ASC
LIMIT ? OFFSET ?
""".trimIndent()
return SimpleSQLiteQuery(sql, arrayOf<Any>(libraryId, pageSize, pageNumber * pageSize))
}
private fun buildAuthorCountQuery(
libraryId: String,
libraryType: LibraryType?,
): SupportSQLiteQuery {
val (join, filter) = hideCompletedClauses(libraryType)
val sql =
"""
SELECT COUNT(DISTINCT $AUTHOR_KEY)
FROM detailed_books
$join
WHERE libraryId = ? AND $AUTHOR_KEY IS NOT NULL AND $AUTHOR_KEY != '' $filter
""".trimIndent()
return SimpleSQLiteQuery(sql, arrayOf<Any>(libraryId))
}
private fun BookEntity.primarySeriesName(): String? =
seriesJson
?.let {
val type = Types.newParameterizedType(List::class.java, BookSeriesDto::class.java)
moshi.adapter<List<BookSeriesDto>>(type).fromJson(it)
}?.firstOrNull()
?.let { CachedBookDao.adapter.fromJson(it) }
?.firstOrNull()
?.title
suspend fun searchBooks(
libraryId: String,
query: String,
limit: Int,
): List<Book> {
val (option, direction) = buildOrdering()
@@ -274,6 +357,7 @@ class CachedBookRepository
.libraryId(libraryId)
.orderField(option)
.orderDirection(direction)
.limit(limit)
.build()
return bookDao
@@ -288,9 +372,8 @@ class CachedBookRepository
)
val progress =
recentBooks
.map { it.id }
.mapNotNull { bookDao.fetchMediaProgress(it) }
bookDao
.fetchMediaProgress(recentBooks.map { it.id })
.associate { it.bookId to (it.lastUpdate to it.currentTime) }
return recentBooks
@@ -311,11 +394,12 @@ class CachedBookRepository
playingItem: DetailedItem,
progress: PlaybackProgress,
) {
val totalDuration = playingItem.chapters.sumOf { it.duration }
val entity =
MediaProgressEntity(
bookId = playingItem.id,
currentTime = progress.currentTotalTime,
isFinished = progress.currentTotalTime == playingItem.chapters.sumOf { it.duration },
isFinished = progress.currentTotalTime >= totalDuration - FINISHED_POSITION_EPSILON,
lastUpdate = Instant.now().toEpochMilli(),
)

View File

@@ -3,6 +3,7 @@ package org.grakovne.lissen.content.cache.persistent.api
import org.grakovne.lissen.content.cache.persistent.converter.CachedLibraryEntityConverter
import org.grakovne.lissen.content.cache.persistent.dao.CachedLibraryDao
import org.grakovne.lissen.domain.Library
import org.grakovne.lissen.domain.LibraryType
import javax.inject.Inject
import javax.inject.Singleton
@@ -15,6 +16,8 @@ class CachedLibraryRepository
) {
suspend fun cacheLibraries(libraries: List<Library>) = dao.updateLibraries(libraries)
suspend fun fetchLibraryType(libraryId: String): LibraryType? = dao.fetchLibrary(libraryId)?.type
suspend fun fetchLibraries() =
dao
.fetchLibraries()

View File

@@ -29,7 +29,43 @@ class FetchRequestBuilder {
fun build(): SupportSQLiteQuery {
val args = mutableListOf<Any>()
val (join, whereClause) = clauses(args)
val field = "b.${resolveOrderField(orderField)}"
val direction = resolveOrderDirection(orderDirection)
args.add(pageSize)
args.add(pageNumber * pageSize)
val sql =
"""
SELECT b.*
FROM detailed_books b
$join
WHERE $whereClause
ORDER BY $field $direction
LIMIT ? OFFSET ?
""".trimIndent()
return SimpleSQLiteQuery(sql, args.toTypedArray())
}
fun buildCount(): SupportSQLiteQuery {
val args = mutableListOf<Any>()
val (join, whereClause) = clauses(args)
val sql =
"""
SELECT COUNT(*)
FROM detailed_books b
$join
WHERE $whereClause
""".trimIndent()
return SimpleSQLiteQuery(sql, args.toTypedArray())
}
private fun clauses(args: MutableList<Any>): Pair<String, String> {
val libraryWhereClause =
when (val id = libraryId) {
null -> {
@@ -42,39 +78,8 @@ class FetchRequestBuilder {
}
}
val hideCompletedWhereClause =
when {
hideCompleted && libraryType == LibraryType.LIBRARY -> "AND (mp.isFinished = 0 OR mp.isFinished IS NULL)"
else -> ""
}
val (join, filter) = hideCompletedSql(hideCompletedApplies(hideCompleted, libraryType), "b.id")
val field =
when (orderField) {
"title" -> "b.title"
"author" -> "b.author"
"duration" -> "b.duration"
else -> "b.title"
}
val direction =
when (orderDirection.uppercase()) {
"ASC", "DESC" -> orderDirection.uppercase()
else -> "ASC"
}
args.add(pageSize)
args.add(pageNumber * pageSize)
val sql =
"""
SELECT b.*
FROM detailed_books b
LEFT JOIN media_progress mp ON mp.bookId = b.id
WHERE $libraryWhereClause $hideCompletedWhereClause
ORDER BY $field $direction
LIMIT ? OFFSET ?
""".trimIndent()
return SimpleSQLiteQuery(sql, args.toTypedArray())
return join to "$libraryWhereClause $filter"
}
}

View File

@@ -0,0 +1,17 @@
package org.grakovne.lissen.content.cache.persistent.api
import org.grakovne.lissen.domain.LibraryType
fun hideCompletedApplies(
hideCompleted: Boolean,
libraryType: LibraryType?,
): Boolean = hideCompleted && libraryType == LibraryType.LIBRARY
fun hideCompletedSql(
apply: Boolean,
idColumn: String,
): Pair<String, String> =
when (apply) {
true -> "LEFT JOIN media_progress mp ON mp.bookId = $idColumn" to "AND (mp.isFinished = 0 OR mp.isFinished IS NULL)"
false -> "" to ""
}

View File

@@ -0,0 +1,13 @@
package org.grakovne.lissen.content.cache.persistent.api
fun resolveOrderField(field: String): String =
when (field) {
"author", "createdAt", "updatedAt" -> field
else -> "title"
}
fun resolveOrderDirection(direction: String): String =
when (direction.uppercase()) {
"ASC", "DESC" -> direction.uppercase()
else -> "ASC"
}

View File

@@ -8,6 +8,7 @@ class SearchRequestBuilder {
private var searchQuery: String = ""
private var orderField: String = "title"
private var orderDirection: String = "ASC"
private var limit: Int? = null
fun libraryId(id: String) = apply { this.libraryId = id }
@@ -17,6 +18,8 @@ class SearchRequestBuilder {
fun orderDirection(direction: String) = apply { this.orderDirection = direction }
fun limit(value: Int) = apply { this.limit = value }
fun build(): SupportSQLiteQuery {
val args = mutableListOf<Any>()
@@ -32,23 +35,23 @@ class SearchRequestBuilder {
args.add(pattern)
}
val field =
when (orderField) {
"title", "author", "duration" -> orderField
else -> "title"
}
val field = resolveOrderField(orderField)
val direction = resolveOrderDirection(orderDirection)
val direction =
when (orderDirection.uppercase()) {
"ASC", "DESC" -> orderDirection.uppercase()
else -> "ASC"
}
val limitClause =
limit
?.let {
args.add(it)
"LIMIT ?"
}
?: ""
val sql =
"""
SELECT * FROM detailed_books
WHERE $whereClause
ORDER BY $field $direction
$limitClause
""".trimIndent()
return SimpleSQLiteQuery(sql, args.toTypedArray())

View File

@@ -21,11 +21,8 @@ class CachedBookEntityConverter
series =
entity
.seriesJson
?.let {
val type = Types.newParameterizedType(List::class.java, BookSeriesDto::class.java)
val adapter = moshi.adapter<List<BookSeriesDto>>(type)
adapter.fromJson(it)
}?.joinToString(", ") { series ->
?.let { seriesAdapter.fromJson(it) }
?.joinToString(", ") { series ->
buildString {
append(series.title)
series.sequence
@@ -34,4 +31,9 @@ class CachedBookEntityConverter
}
},
)
private companion object {
val seriesType = Types.newParameterizedType(List::class.java, BookSeriesDto::class.java)
val seriesAdapter = moshi.adapter<List<BookSeriesDto>>(seriesType)
}
}

View File

@@ -29,11 +29,8 @@ class CachedBookEntityDetailedConverter
entity
.detailedBook
.authorsJson
?.let {
val type = Types.newParameterizedType(List::class.java, BookAuthorDto::class.java)
val adapter = moshi.adapter<List<BookAuthorDto>>(type)
adapter.fromJson(it)
}?.map { BookAuthor(id = it.id, name = it.name) }
?.let { authorsAdapter.fromJson(it) }
?.map { BookAuthor(id = it.id, name = it.name) }
?: emptyList(),
narrator = entity.detailedBook.narrator,
libraryId = entity.detailedBook.libraryId,
@@ -69,11 +66,8 @@ class CachedBookEntityDetailedConverter
entity
.detailedBook
.seriesJson
?.let {
val type = Types.newParameterizedType(List::class.java, BookSeriesDto::class.java)
val adapter = moshi.adapter<List<BookSeriesDto>>(type)
adapter.fromJson(it)
}?.map {
?.let { seriesAdapter.fromJson(it) }
?.map {
BookSeries(
id = it.id,
name = it.title,
@@ -82,4 +76,11 @@ class CachedBookEntityDetailedConverter
} ?: emptyList(),
progress = entity.progress?.let { mediaProgressEntityConverter.apply(it) },
)
private companion object {
val authorsType = Types.newParameterizedType(List::class.java, BookAuthorDto::class.java)
val authorsAdapter = moshi.adapter<List<BookAuthorDto>>(authorsType)
val seriesType = Types.newParameterizedType(List::class.java, BookSeriesDto::class.java)
val seriesAdapter = moshi.adapter<List<BookSeriesDto>>(seriesType)
}
}

View File

@@ -12,6 +12,7 @@ import androidx.sqlite.db.SupportSQLiteQuery
import com.squareup.moshi.Types
import kotlinx.coroutines.flow.Flow
import org.grakovne.lissen.common.moshi
import org.grakovne.lissen.content.cache.persistent.entity.AuthorEntry
import org.grakovne.lissen.content.cache.persistent.entity.BookAuthorDto
import org.grakovne.lissen.content.cache.persistent.entity.BookChapterEntity
import org.grakovne.lissen.content.cache.persistent.entity.BookEntity
@@ -139,13 +140,11 @@ interface CachedBookDao {
@RawQuery
suspend fun fetchGroupedEntries(query: SupportSQLiteQuery): List<GroupedEntry>
@Query(
"""
SELECT COUNT(DISTINCT COALESCE(seriesId, id)) FROM detailed_books
WHERE libraryId = :libraryId
""",
)
suspend fun countGroupedEntries(libraryId: String): Int
@RawQuery
suspend fun fetchAuthorEntries(query: SupportSQLiteQuery): List<AuthorEntry>
@RawQuery
suspend fun countRaw(query: SupportSQLiteQuery): Int
@Query("SELECT * FROM detailed_books WHERE id IN (:ids)")
suspend fun fetchBooksByIds(ids: List<String>): List<BookEntity>
@@ -153,14 +152,6 @@ interface CachedBookDao {
@Query("SELECT * FROM detailed_books WHERE seriesId IN (:seriesIds)")
suspend fun fetchBooksBySeriesIds(seriesIds: List<String>): List<BookEntity>
@Query(
"""
SELECT COUNT(*) FROM detailed_books
WHERE (libraryId = :libraryId)
""",
)
suspend fun countCachedBooks(libraryId: String?): Int
@Transaction
@RawQuery
suspend fun searchBooks(query: SupportSQLiteQuery): List<BookEntity>
@@ -224,6 +215,16 @@ interface CachedBookDao {
chapterId: String,
): Flow<Boolean>
@Query(
"""
SELECT bookChapterId
FROM book_chapters
WHERE bookId = :bookId
AND isCached = 1
""",
)
fun cachedChapterIds(bookId: String): Flow<List<String>>
@Query(
"""
SELECT MAX(mp.lastUpdate)
@@ -254,6 +255,10 @@ interface CachedBookDao {
@Query("SELECT * FROM media_progress WHERE bookId = :bookId")
suspend fun fetchMediaProgress(bookId: String): MediaProgressEntity?
@Transaction
@Query("SELECT * FROM media_progress WHERE bookId IN (:bookIds)")
suspend fun fetchMediaProgress(bookIds: List<String>): List<MediaProgressEntity>
@Delete
suspend fun deleteBook(book: BookEntity)

View File

@@ -0,0 +1,9 @@
package org.grakovne.lissen.content.cache.persistent.entity
import androidx.annotation.Keep
@Keep
data class AuthorEntry(
val author: String,
val bookCount: Int,
)

View File

@@ -32,7 +32,10 @@ data class CachedBookEntity(
)
@Keep
@Entity(tableName = "detailed_books")
@Entity(
tableName = "detailed_books",
indices = [Index(value = ["libraryId"]), Index(value = ["seriesId"])],
)
@JsonClass(generateAdapter = true)
data class BookEntity(
@PrimaryKey val id: String,

View File

@@ -14,7 +14,6 @@ import org.grakovne.lissen.content.cache.common.SeriesCoverComposer
import org.grakovne.lissen.content.cache.common.writeToFile
import timber.log.Timber
import java.io.File
import java.security.MessageDigest
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
@@ -97,9 +96,5 @@ class SeriesCoverProvider
private fun cacheKey(
seriesId: String,
coverItemIds: List<String>,
): String =
MessageDigest
.getInstance("SHA-256")
.digest((seriesId + coverItemIds.joinToString(",")).toByteArray())
.joinToString("") { "%02x".format(it) }
): String = seriesId + coverItemIds.joinToString(",")
}

View File

@@ -2,6 +2,7 @@ package org.grakovne.lissen.content.cache.temporary
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import org.grakovne.lissen.content.cache.common.toFileKey
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
@@ -14,11 +15,11 @@ class ShortTermCacheStorageProperties
) {
fun provideCoverCacheFolder(): File = coverCacheFolder()
fun provideCoverPath(itemId: String): File = coverCacheFolder().resolve(itemId)
fun provideCoverPath(itemId: String): File = coverCacheFolder().resolve(itemId.toFileKey())
fun provideSeriesCoverCacheFolder(): File = seriesCoverCacheFolder()
fun provideSeriesCoverPath(key: String): File = seriesCoverCacheFolder().resolve(key)
fun provideSeriesCoverPath(key: String): File = seriesCoverCacheFolder().resolve(key.toFileKey())
private fun coverCacheFolder(): File =
baseFolder()

View File

@@ -31,6 +31,6 @@ fun String?.makeDownloadOption(): DownloadOption? =
this == "all_items" -> AllItemsDownloadOption
this == "current_item" -> CurrentItemDownloadOption
this == "remaining_items" -> RemainingItemsDownloadOption
startsWith("number_items_") -> NumberItemDownloadOption(substringAfter("number_items_").toInt())
startsWith("number_items_") -> substringAfter("number_items_").toIntOrNull()?.let { NumberItemDownloadOption(it) }
else -> null
}

View File

@@ -17,7 +17,7 @@ class AppearancePreferences
fun getColorScheme(): ColorScheme =
store
.getString(KEY_PREFERRED_COLOR_SCHEME, ColorScheme.FOLLOW_SYSTEM.name)
?.let { ColorScheme.valueOf(it) }
?.let { runCatching { ColorScheme.valueOf(it) }.getOrNull() }
?: ColorScheme.FOLLOW_SYSTEM
fun saveColorScheme(colorScheme: ColorScheme) = store.putString(KEY_PREFERRED_COLOR_SCHEME, colorScheme.name)

View File

@@ -33,15 +33,13 @@ class ConnectionPreferences
fun clearClientCertAlias() = store.remove(KEY_CLIENT_CERT_ALIAS)
fun saveCustomHeaders(headers: List<ServerRequestHeader>) {
val type = Types.newParameterizedType(List::class.java, ServerRequestHeader::class.java)
val adapter = moshi.adapter<List<ServerRequestHeader>>(type)
val adapter = moshi.adapter<List<ServerRequestHeader>>(customHeadersType)
store.putString(KEY_CUSTOM_HEADERS, adapter.toJson(headers))
}
fun getCustomHeaders(): List<ServerRequestHeader> {
val json = store.getString(KEY_CUSTOM_HEADERS) ?: return emptyList()
val type = Types.newParameterizedType(List::class.java, ServerRequestHeader::class.java)
val adapter = moshi.adapter<List<ServerRequestHeader>>(type)
val adapter = moshi.adapter<List<ServerRequestHeader>>(customHeadersType)
return adapter.fromJson(json) ?: emptyList()
}
@@ -77,6 +75,9 @@ class ConnectionPreferences
}
companion object {
private val customHeadersType =
Types.newParameterizedType(List::class.java, ServerRequestHeader::class.java)
private const val KEY_CUSTOM_HEADERS = "custom_headers"
private const val KEY_BYPASS_SSL = "bypass_ssl"
private const val KEY_LOCAL_URLS = "local_urls"

View File

@@ -23,7 +23,7 @@ class DownloadPreferences
fun getAutoDownloadNetworkType(): NetworkTypeAutoCache =
store
.getString(KEY_PREFERRED_AUTO_DOWNLOAD_NETWORK_TYPE, NetworkTypeAutoCache.WIFI_ONLY.name)
?.let { NetworkTypeAutoCache.valueOf(it) }
?.let { runCatching { NetworkTypeAutoCache.valueOf(it) }.getOrNull() }
?: NetworkTypeAutoCache.WIFI_ONLY
fun saveAutoDownloadNetworkType(networkTypeAutoCache: NetworkTypeAutoCache) =

View File

@@ -85,7 +85,7 @@ class LibraryPreferences
private fun getPreferredLibraryType(): LibraryType =
store
.getString(KEY_PREFERRED_LIBRARY_TYPE)
?.let { LibraryType.valueOf(it) }
?.let { runCatching { LibraryType.valueOf(it) }.getOrNull() }
?: LibraryType.LIBRARY
companion object {

View File

@@ -27,7 +27,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import org.grakovne.lissen.content.LissenMediaProvider
import org.grakovne.lissen.persistence.preferences.PlaybackPreferences
import org.grakovne.lissen.playback.service.PlaybackService
@@ -206,10 +205,8 @@ class MediaLibrarySessionCallback
.fetchBook(bookId)
.foldAsync(
onSuccess = {
async {
preferences.savePlayingItem(it)
playbackSynchronizationService.startPlaybackSynchronization(it)
}
preferences.savePlayingItem(it)
playbackSynchronizationService.startPlaybackSynchronization(it)
mediaRepository.registerPlayingBook(it)
PlaybackService.bookToChapterMediaItems(it)
},
@@ -256,7 +253,9 @@ class MediaLibrarySessionCallback
pageSize: Int,
params: MediaLibraryService.LibraryParams?,
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
val searchFuture = searchCache.get(query)
val searchFuture =
searchCache.get(query)
?: return Futures.immediateFuture(LibraryResult.ofItemList(ImmutableList.of(), params))
return Futures.transform(
searchFuture,
{ items ->

View File

@@ -109,6 +109,14 @@ class MediaLibraryTree
private val scope = CoroutineScope(Dispatchers.Default)
private suspend fun libraries(): List<Library> =
lissenMediaProvider
.fetchLibraries()
.fold(
onSuccess = { it },
onFailure = { emptyList() },
)
private val root: MediaTreeNode by lazy { buildTree() }
@OptIn(UnstableApi::class)
@@ -134,7 +142,7 @@ class MediaLibraryTree
}
+mediaTreeNode(folderItem("$ROOT/$DOWNLOADS", context.getString(R.string.tree_node_downloads))) {
pagedChildren { _, _ -> downloadedBooksItems() }
pagedChildren { page, pageSize -> downloadedBooksItems(page, pageSize) }
}
}
@@ -262,21 +270,9 @@ class MediaLibraryTree
)
} ?: emptyList()
private suspend fun libraryItems(): List<MediaItem> =
lissenMediaProvider
.fetchLibraries()
.fold(
onSuccess = { libs -> libs.map { libraryFolderItem("$ROOT/$LIBRARY/${it.id}", it) } },
onFailure = { emptyList() },
)
private suspend fun libraryItems(): List<MediaItem> = libraries().map { libraryFolderItem("$ROOT/$LIBRARY/${it.id}", it) }
private suspend fun resolveLibrary(libId: String): Library? =
lissenMediaProvider
.fetchLibraries()
.fold(
onSuccess = { libs -> libs.find { it.id == libId } },
onFailure = { null },
)
private suspend fun resolveLibrary(libId: String): Library? = libraries().find { it.id == libId }
private suspend fun booksFromLibraryItems(
libraryId: String,
@@ -290,9 +286,12 @@ class MediaLibraryTree
onFailure = { emptyList() },
)
private suspend fun downloadedBooksItems(): List<MediaItem> =
private suspend fun downloadedBooksItems(
page: Int,
pageSize: Int,
): List<MediaItem> =
localCacheRepository
.fetchDetailedItems()
.fetchDetailedItems(pageSize = pageSize, pageNumber = page)
.fold(
onSuccess = { paged -> paged.items.map { bookItem(it) } },
onFailure = { emptyList() },

View File

@@ -14,10 +14,8 @@ import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.MoreExecutors
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -37,7 +35,6 @@ import org.grakovne.lissen.playback.service.DefaultTimerActivator
import org.grakovne.lissen.playback.service.PlaybackService
import org.grakovne.lissen.playback.service.calculateChapterIndex
import org.grakovne.lissen.playback.service.calculateChapterIndexAndPosition
import org.grakovne.lissen.playback.service.calculateChapterPosition
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@@ -126,7 +123,7 @@ class MediaRepository
is PlaybackEvent.PlaybackReady -> {
val book = preferences.getPlayingItem()
book?.let {
updateProgress(book).await()
updateProgress(book)
if (mediaController.isPlaying) {
progressPoller.start()
@@ -438,19 +435,21 @@ class MediaRepository
preferences.savePlayingItem(book)
eventBus.send(PlaybackCommand.PreparePlayback)
} else {
_isPlaybackReady.value = true
}
}
private fun updateProgress(detailedItem: DetailedItem): Deferred<Unit> =
scope.async {
val currentIndex = mediaController.currentMediaItemIndex
val accumulated = detailedItem.chapters.take(currentIndex).sumOf { it.duration }
val currentFilePosition = mediaController.currentPosition / 1000.0
private fun updateProgress(detailedItem: DetailedItem) {
val currentIndex = mediaController.currentMediaItemIndex
val chapters = detailedItem.chapters
val accumulated = chapters.take(currentIndex.coerceIn(0, chapters.size)).sumOf { it.duration }
val currentFilePosition = mediaController.currentPosition / 1000.0
val newPosition = accumulated + currentFilePosition
_totalPosition.value = newPosition
updateCurrentTrackData()
}
val newPosition = accumulated + currentFilePosition
_totalPosition.value = newPosition
updateCurrentTrackData()
}
private fun play() {
withMain {
@@ -497,18 +496,18 @@ class MediaRepository
var safePosition = minOf(overallDuration, maxOf(0.0, position))
while (book.chapters[calculateChapterIndex(book, safePosition)].available.not()) {
val chapterIndex =
val startIndex = calculateChapterIndex(book, safePosition)
if (startIndex in book.chapters.indices && book.chapters[startIndex].available.not()) {
val forward = (startIndex..book.chapters.lastIndex).firstOrNull { book.chapters[it].available }
val backward = (startIndex downTo 0).firstOrNull { book.chapters[it].available }
val target =
when (direction) {
ScrollingDirection.FORWARD -> calculateChapterIndex(book, safePosition) + 1
ScrollingDirection.BACKWARD -> calculateChapterIndex(book, safePosition) - 1
ScrollingDirection.FORWARD -> forward ?: backward
ScrollingDirection.BACKWARD -> backward ?: forward
}
safePosition =
when {
chapterIndex in 0..book.chapters.lastIndex -> book.chapters[chapterIndex].start
else -> break
}
target?.let { safePosition = book.chapters[it].start }
}
val (chapterIndex, chapterPosition) = calculateChapterIndexAndPosition(book, safePosition)
@@ -542,8 +541,7 @@ class MediaRepository
val book = playingBook.value ?: return
val totalPosition = totalPosition.value
val trackIndex = calculateChapterIndex(book, totalPosition)
val trackPosition = calculateChapterPosition(book, totalPosition)
val (trackIndex, trackPosition) = calculateChapterIndexAndPosition(book, totalPosition)
_currentChapterIndex.value = trackIndex
_currentChapterPosition.value = trackPosition
@@ -560,7 +558,12 @@ class MediaRepository
val playingBook = _playingBook.value ?: return
val totalPosition = _totalPosition.value
val currentChapter = playingBook.chapters[calculateChapterIndex(playingBook, totalPosition)].title
val chapterIndex = calculateChapterIndex(playingBook, totalPosition)
if (chapterIndex !in playingBook.chapters.indices) {
Timber.w("Unable to create bookmark: chapter index $chapterIndex out of bounds")
return
}
val currentChapter = playingBook.chapters[chapterIndex].title
val chapterPosition = _currentChapterPosition.value
val bookmarkTitle =

View File

@@ -51,11 +51,15 @@ class PlaybackEnhancerService
attachEqualizer(player.audioSessionId, sharedPreferences.getEqualizer())
scope.launch {
sharedPreferences.playbackVolumeBoostFlow.collectLatest { updateGain(it) }
sharedPreferences.playbackVolumeBoostFlow.collectLatest {
withContext(Dispatchers.Main) { updateGain(it) }
}
}
scope.launch {
sharedPreferences.equalizerFlow.collectLatest { applyEqualizer(it) }
sharedPreferences.equalizerFlow.collectLatest {
withContext(Dispatchers.Main) { applyEqualizer(it) }
}
}
scope.launch {

View File

@@ -83,7 +83,7 @@ internal class LocalFallbackDataSource(
private var usingLocal: Boolean = false
override fun open(dataSpec: DataSpec): Long {
val (itemId, fileId) = unapply(dataSpec.uri) ?: return openPassthrough(dataSpec)
val (itemId, fileId) = parseLissenUri(dataSpec.uri) ?: return openPassthrough(dataSpec)
val resolvedUri =
mediaProvider

View File

@@ -2,7 +2,7 @@ package org.grakovne.lissen.playback.service
import android.net.Uri
fun apply(
fun toLissenUri(
mediaItemId: String,
fileId: String,
): Uri =
@@ -13,7 +13,7 @@ fun apply(
.appendPath(fileId)
.build()
fun unapply(uri: Uri): Pair<String, String>? {
fun parseLissenUri(uri: Uri): Pair<String, String>? {
if (uri.scheme != "lissen") return null
val segments = uri.pathSegments

View File

@@ -68,7 +68,7 @@ class LissenMediaSourceFactory(
.createMediaSource(
MediaItem
.Builder()
.setUri(apply(bookId, fileId))
.setUri(toLissenUri(bookId, fileId))
.apply { metadata?.let { setMediaMetadata(it) } }
.build(),
).let {

View File

@@ -1,6 +1,7 @@
package org.grakovne.lissen.playback.service
import androidx.annotation.OptIn
import androidx.core.os.BundleCompat
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
@@ -52,7 +53,7 @@ class PlaybackNavigationService
return
}
if (exoPlayer.currentMediaItem?.mediaId?.let { isTrackAvailable(it) } != false) {
if (isTrackAvailable(exoPlayer.currentMediaItemIndex)) {
return
}
@@ -66,10 +67,16 @@ class PlaybackNavigationService
false -> Direction.BACKWARD
}
val nextTrack = findAvailableTrackIndex(exoPlayer.currentMediaItemIndex, direction, exoPlayer, 0)
val nextTrack = findAvailableTrackIndex(exoPlayer.currentMediaItemIndex, direction, exoPlayer)
nextTrack?.let { exoPlayer.seekTo(it, 0) }
if (nextTrack == null || nextTrack < currentIndex) {
val wrapped =
when (direction) {
Direction.FORWARD -> nextTrack != null && nextTrack < currentIndex
Direction.BACKWARD -> nextTrack != null && nextTrack > currentIndex
}
if (nextTrack == null || wrapped) {
exoPlayer.pause()
playbackSynchronizationService.cancelSynchronization()
}
@@ -80,42 +87,63 @@ class PlaybackNavigationService
}
private fun findAvailableTrackIndex(
currentItem: Int,
startIndex: Int,
direction: Direction,
exoPlayer: ExoPlayer,
iteration: Int,
): Int? {
if (isTrackAvailable(exoPlayer.getMediaItemAt(currentItem).mediaId)) {
return currentItem
}
if (iteration > 4096) {
val count = exoPlayer.mediaItemCount
if (count == 0) {
return null
}
val foundItem =
when (direction) {
Direction.FORWARD -> (currentItem + 1) % exoPlayer.mediaItemCount
Direction.BACKWARD -> if (currentItem - 1 < 0) exoPlayer.mediaItemCount - 1 else currentItem - 1
var index = startIndex
repeat(count) {
if (isTrackAvailable(index)) {
return index
}
return findAvailableTrackIndex(foundItem, direction, exoPlayer, iteration + 1)
index =
when (direction) {
Direction.FORWARD -> (index + 1) % count
Direction.BACKWARD -> if (index - 1 < 0) count - 1 else index - 1
}
}
return null
}
private fun isTrackAvailable(fileId: String): Boolean {
val mediaItem =
exoPlayer
.currentMediaItem
?.localConfiguration
?.tag as? DetailedItem
private fun isTrackAvailable(index: Int): Boolean {
if (index < 0 || index >= exoPlayer.mediaItemCount) {
return false
}
val mediaItem = exoPlayer.getMediaItemAt(index)
val bookId =
LissenMediaSourceFactory.MediaId
.fromString(mediaItem.mediaId)
?.bookId
?: return false
return mediaProvider
.provideFileUri(mediaItem.id, fileId)
.fold(
onSuccess = { true },
onFailure = { false },
)
val segments =
mediaItem
.requestMetadata
.extras
?.let { BundleCompat.getParcelableArrayList(it, PlaybackService.FILE_SEGMENTS, FileClip::class.java) }
?: return false
if (segments.isEmpty()) {
return false
}
return segments.all { clip ->
mediaProvider
.provideFileUri(bookId, clip.fileId)
.fold(
onSuccess = { true },
onFailure = { false },
)
}
}
}

View File

@@ -228,7 +228,10 @@ class PlaybackService : MediaLibraryService() {
?: ChapterPosition(0, 0.0)
val negativeChapter = chapterIndex < 0
val lastMoments = chapterIndex == book.chapters.lastIndex && (book.chapters.last().end - 5) < chapterOffset
val lastMoments =
!negativeChapter &&
book.chapters.isNotEmpty() &&
(book.chapters.last().end - 5) < (book.progress?.currentTime ?: 0.0)
if (negativeChapter || lastMoments) {
chapterIndex = 0

View File

@@ -125,10 +125,10 @@ class PlaybackSynchronizationService
currentChapterIndex = currentIndex
}
playbackSession?.let {
playbackSession?.let { session ->
requestSync(
item = currentItem,
it = it,
session = session,
overallProgress = overallProgress,
)
}
@@ -136,12 +136,12 @@ class PlaybackSynchronizationService
private suspend fun requestSync(
item: DetailedItem,
it: PlaybackSession,
session: PlaybackSession,
overallProgress: PlaybackProgress,
): Unit? =
mediaChannel
.syncProgress(
sessionId = it.sessionId,
sessionId = session.sessionId,
detailedItem = item,
progress = overallProgress,
).foldAsync(
@@ -196,14 +196,15 @@ class PlaybackSynchronizationService
internal const val SYNC_INTERVAL_LONG = 45_000L
internal const val SYNC_INTERVAL_SHORT = 5_000L
internal const val SHORT_SYNC_WINDOW = SYNC_INTERVAL_LONG * 2 - 1
private const val SHORT_SYNC_WINDOW_MS = 90_000L
internal fun chooseSyncInterval(
durationMs: Long,
positionMs: Long,
): Long {
val nearStart = positionMs < SHORT_SYNC_WINDOW
val nearEnd = durationMs != C.TIME_UNSET && durationMs - positionMs < SHORT_SYNC_WINDOW
val nearStart = positionMs < SHORT_SYNC_WINDOW_MS
val nearEnd = durationMs != C.TIME_UNSET && durationMs - positionMs < SHORT_SYNC_WINDOW_MS
return when (nearStart || nearEnd) {
true -> SYNC_INTERVAL_SHORT

View File

@@ -137,8 +137,8 @@ fun LibraryScreen(
val library = libraryViewModel.getPager(searchRequested).collectAsLazyPagingItems()
val libraryCount by libraryViewModel.totalCount.collectAsState()
val expandedGroups by libraryViewModel.expandedGroups.collectAsState()
val groupBooks by libraryViewModel.groupBooks.collectAsState()
val groupLoading by libraryViewModel.groupLoading.collectAsState()
val groupBooks = libraryViewModel.groupBooks
val groupLoading = libraryViewModel.groupLoading
val libraryGrouping by settingsViewModel.libraryGrouping.collectAsState(LibraryGrouping.NONE)
val libraryListState = rememberLazyGridState()

View File

@@ -234,11 +234,13 @@ private fun PlaybackProgressButton(
val isPlaying by playerViewModel.isPlaying.collectAsState()
val totalPosition by playerViewModel.totalPosition.collectAsState()
val totalDuration = remember(book.id) { book.chapters.sumOf { it.duration } }
val progress =
calculateProgress(
item = book,
libraryType = libraryType,
totalPosition = totalPosition,
totalDuration = totalDuration,
)
PlaybackButton(
@@ -249,17 +251,14 @@ private fun PlaybackProgressButton(
}
private fun calculateProgress(
item: DetailedItem,
libraryType: LibraryType?,
totalPosition: Double,
): Float {
val totalDuration = item.chapters.sumOf { it.duration }
return when (totalDuration > 0 && libraryType == LibraryType.LIBRARY) {
totalDuration: Double,
): Float =
when (totalDuration > 0 && libraryType == LibraryType.LIBRARY) {
true -> (totalPosition / totalDuration).toFloat().coerceIn(0f, 1f).snapProgress()
false -> 0f
}
}
@Composable
private fun PlaybackButton(

View File

@@ -221,16 +221,6 @@ fun QuickSettingsComposable(
}
}
@Composable
private fun SectionHeader(title: String) {
Text(
text = title,
style = typography.labelMedium,
color = colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 4.dp),
)
}
@Composable
private fun ToggleRow(
title: String,

View File

@@ -64,7 +64,7 @@ fun NavigationBarComposable(
val playingQueueExpanded by playerViewModel.playingQueueExpanded.collectAsState()
val hasEpisodes = book.chapters.isNotEmpty()
val isMetadataCached by contentCachingModelView.provideCacheState(book.id).collectAsState(initial = false)
val isMetadataCached by remember(book.id) { contentCachingModelView.provideCacheState(book.id) }.collectAsState(initial = false)
val totalPosition by playerViewModel.totalPosition.collectAsState()
val remainingChapters = (book.chapters.size - calculateChapterIndex(book, totalPosition)).coerceAtLeast(1)

View File

@@ -59,6 +59,8 @@ import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.android.awaitFrame
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.grakovne.lissen.R
import org.grakovne.lissen.ui.components.withScrollbar
@@ -107,6 +109,16 @@ fun PlayingQueueComposable(
}
}
val bookId = book?.id ?: ""
val cachedChapterIdsFlow =
remember(bookId) {
when (bookId.isEmpty()) {
true -> flowOf(emptySet())
false -> cachingModelView.provideCachedChapterIds(bookId).map { it.toSet() }
}
}
val cachedChapterIds by cachedChapterIdsFlow.collectAsState(initial = emptySet())
val playbackReady by viewModel.isPlaybackReady.collectAsState()
val playingQueueExpanded by viewModel.playingQueueExpanded.collectAsState()
@@ -298,21 +310,13 @@ fun PlayingQueueComposable(
showingChapters,
key = { _, chapter -> chapter.id },
) { index, chapter ->
val bookId = book?.id ?: ""
val cacheStateFlow =
remember(bookId, chapter.id) {
cachingModelView.provideCacheState(bookId = bookId, chapterId = chapter.id)
}
val isCached by cacheStateFlow.collectAsState(initial = false)
PlaylistItemComposable(
track = chapter,
onClick = { viewModel.setChapter(chapter) },
isSelected = chapter.id == currentTrackId?.id,
modifier = Modifier.wrapContentWidth(),
maxDuration = maxDuration,
isCached = isCached,
isCached = chapter.id in cachedChapterIds,
)
if (index < showingChapters.size - 1) {

View File

@@ -36,6 +36,7 @@ import org.grakovne.lissen.ui.navigation.AppNavigationService
import org.grakovne.lissen.ui.screens.settings.advanced.AdvancedSettingsNavigationItemComposable
import org.grakovne.lissen.ui.screens.settings.composable.GitHubLinkComposable
import org.grakovne.lissen.ui.screens.settings.composable.LicenseFooterComposable
import org.grakovne.lissen.ui.screens.settings.composable.SettingsTopAppBar
import org.grakovne.lissen.viewmodel.SettingsViewModel
@Composable
@@ -53,24 +54,9 @@ fun SettingsScreen(
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(R.string.settings_screen_title),
style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
modifier = Modifier.semantics { heading() },
color = colorScheme.onSurface,
)
},
navigationIcon = {
IconButton(onClick = { onBack() }) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = stringResource(R.string.a11y_back),
tint = colorScheme.onSurface,
)
}
},
SettingsTopAppBar(
title = stringResource(R.string.settings_screen_title),
onBack = onBack,
)
},
modifier =

View File

@@ -39,6 +39,7 @@ import org.grakovne.lissen.common.shareFile
import org.grakovne.lissen.ui.navigation.AppNavigationService
import org.grakovne.lissen.ui.screens.settings.composable.SettingsInfoBanner
import org.grakovne.lissen.ui.screens.settings.composable.SettingsToggleItem
import org.grakovne.lissen.ui.screens.settings.composable.SettingsTopAppBar
import org.grakovne.lissen.viewmodel.CachingModelView
import org.grakovne.lissen.viewmodel.SettingsViewModel
@@ -59,24 +60,9 @@ fun AdvancedSettingsComposable(
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(R.string.settings_screen_advanced_preferences_title),
style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
modifier = Modifier.semantics { heading() },
color = colorScheme.onSurface,
)
},
navigationIcon = {
IconButton(onClick = { onBack() }) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = stringResource(R.string.a11y_back),
tint = colorScheme.onSurface,
)
}
},
SettingsTopAppBar(
title = stringResource(R.string.settings_screen_advanced_preferences_title),
onBack = onBack,
)
},
modifier =

View File

@@ -33,6 +33,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import org.grakovne.lissen.R
import org.grakovne.lissen.ui.screens.settings.composable.ColorSchemeSettingsComposable
import org.grakovne.lissen.ui.screens.settings.composable.SettingsToggleItem
import org.grakovne.lissen.ui.screens.settings.composable.SettingsTopAppBar
import org.grakovne.lissen.viewmodel.SettingsViewModel
@Composable
@@ -43,24 +44,9 @@ fun AppearancePreferencesScreen(onBack: () -> Unit) {
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(R.string.appearance_preferences_title),
style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
modifier = Modifier.semantics { heading() },
color = colorScheme.onSurface,
)
},
navigationIcon = {
IconButton(onClick = { onBack() }) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = stringResource(R.string.a11y_back),
tint = colorScheme.onSurface,
)
}
},
SettingsTopAppBar(
title = stringResource(R.string.appearance_preferences_title),
onBack = onBack,
)
},
modifier =

View File

@@ -40,6 +40,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import org.grakovne.lissen.R
import org.grakovne.lissen.ui.screens.settings.composable.SettingsTopAppBar
import org.grakovne.lissen.viewmodel.SettingsViewModel
@Composable
@@ -75,22 +76,9 @@ fun ClientCertificateSettingsScreen(onBack: () -> Unit) {
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(R.string.settings_screen_client_cert_title),
style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
modifier = Modifier.semantics { heading() },
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = stringResource(R.string.a11y_back),
)
}
},
SettingsTopAppBar(
title = stringResource(R.string.settings_screen_client_cert_title),
onBack = onBack,
)
},
modifier =

View File

@@ -40,6 +40,7 @@ import org.grakovne.lissen.R
import org.grakovne.lissen.common.restartApplication
import org.grakovne.lissen.common.shareFile
import org.grakovne.lissen.ui.screens.settings.composable.SettingsInfoBanner
import org.grakovne.lissen.ui.screens.settings.composable.SettingsTopAppBar
import org.grakovne.lissen.viewmodel.SettingsViewModel
@OptIn(ExperimentalMaterial3Api::class)
@@ -80,24 +81,9 @@ fun ConfigBackupSettingsScreen(onBack: () -> Unit) {
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(R.string.config_backup_title),
style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
modifier = Modifier.semantics { heading() },
color = colorScheme.onSurface,
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = stringResource(R.string.a11y_back),
tint = colorScheme.onSurface,
)
}
},
SettingsTopAppBar(
title = stringResource(R.string.config_backup_title),
onBack = onBack,
)
},
modifier =

View File

@@ -46,6 +46,7 @@ import org.grakovne.lissen.ui.navigation.AppNavigationService
import org.grakovne.lissen.ui.screens.settings.composable.DisconnectServerComposable
import org.grakovne.lissen.ui.screens.settings.composable.ServerInfoComposable
import org.grakovne.lissen.ui.screens.settings.composable.SettingsToggleItem
import org.grakovne.lissen.ui.screens.settings.composable.SettingsTopAppBar
import org.grakovne.lissen.viewmodel.SettingsViewModel
@Composable
@@ -63,22 +64,9 @@ fun ConnectionSettingsScreen(
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(R.string.connection_settings_title),
style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
modifier = Modifier.semantics { heading() },
)
},
navigationIcon = {
IconButton(onClick = { onBack() }) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = stringResource(R.string.a11y_back),
)
}
},
SettingsTopAppBar(
title = stringResource(R.string.connection_settings_title),
onBack = onBack,
)
},
modifier =

View File

@@ -38,6 +38,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import kotlinx.coroutines.launch
import org.grakovne.lissen.R
import org.grakovne.lissen.domain.connection.ServerRequestHeader
import org.grakovne.lissen.ui.screens.settings.composable.SettingsTopAppBar
import org.grakovne.lissen.viewmodel.SettingsViewModel
import kotlin.math.max
@@ -55,26 +56,9 @@ fun CustomHeadersSettingsScreen(onBack: () -> Unit) {
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(R.string.custom_headers_title),
style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
modifier = Modifier.semantics { heading() },
color = colorScheme.onSurface,
)
},
navigationIcon = {
IconButton(onClick = {
onBack()
}) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = stringResource(R.string.a11y_back),
tint = colorScheme.onSurface,
)
}
},
SettingsTopAppBar(
title = stringResource(R.string.custom_headers_title),
onBack = onBack,
)
},
modifier =

View File

@@ -52,6 +52,7 @@ import org.grakovne.lissen.ui.screens.common.hasLocationPermission
import org.grakovne.lissen.ui.screens.common.localNetworkPermission
import org.grakovne.lissen.ui.screens.common.locationPermission
import org.grakovne.lissen.ui.screens.settings.composable.SettingsInfoBanner
import org.grakovne.lissen.ui.screens.settings.composable.SettingsTopAppBar
import org.grakovne.lissen.viewmodel.SettingsViewModel
import kotlin.math.max
@@ -76,26 +77,9 @@ fun LocalUrlSettingsScreen(onBack: () -> Unit) {
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(R.string.settings_screen_internal_connection_url_title),
style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
modifier = Modifier.semantics { heading() },
color = colorScheme.onSurface,
)
},
navigationIcon = {
IconButton(onClick = {
onBack()
}) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = stringResource(R.string.a11y_back),
tint = colorScheme.onSurface,
)
}
},
SettingsTopAppBar(
title = stringResource(R.string.settings_screen_internal_connection_url_title),
onBack = onBack,
)
},
modifier =

View File

@@ -39,6 +39,7 @@ import org.grakovne.lissen.ui.screens.settings.composable.EqualizerSettingsCompo
import org.grakovne.lissen.ui.screens.settings.composable.PlaybackVolumeBoostSettingsComposable
import org.grakovne.lissen.ui.screens.settings.composable.SettingsInfoBanner
import org.grakovne.lissen.ui.screens.settings.composable.SettingsToggleItem
import org.grakovne.lissen.ui.screens.settings.composable.SettingsTopAppBar
import org.grakovne.lissen.viewmodel.SettingsViewModel
@Composable
@@ -54,24 +55,9 @@ fun PlaybackPreferencesScreen(
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(R.string.playback_preferences_title),
style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
modifier = Modifier.semantics { heading() },
color = colorScheme.onSurface,
)
},
navigationIcon = {
IconButton(onClick = { onBack() }) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = stringResource(R.string.a11y_back),
tint = colorScheme.onSurface,
)
}
},
SettingsTopAppBar(
title = stringResource(R.string.playback_preferences_title),
onBack = onBack,
)
},
modifier =

View File

@@ -50,6 +50,7 @@ import org.grakovne.lissen.R
import org.grakovne.lissen.common.withHaptic
import org.grakovne.lissen.ui.components.LissenModalBottomSheet
import org.grakovne.lissen.ui.components.slider.SeekTimeSlider
import org.grakovne.lissen.ui.screens.settings.composable.SettingsTopAppBar
import org.grakovne.lissen.viewmodel.SettingsViewModel
@Composable
@@ -63,22 +64,9 @@ fun SeekSettingsScreen(onBack: () -> Unit) {
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(R.string.settings_screen_seek_time_title),
style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
modifier = Modifier.semantics { heading() },
)
},
navigationIcon = {
IconButton(onClick = { onBack() }) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = stringResource(R.string.a11y_back),
)
}
},
SettingsTopAppBar(
title = stringResource(R.string.settings_screen_seek_time_title),
onBack = onBack,
)
},
modifier =
@@ -114,7 +102,7 @@ fun SeekSettingsScreen(onBack: () -> Unit) {
title = stringResource(R.string.rewind_interval),
currentSeconds = preferredSeekTime.rewind,
onDismissRequest = { rewindExpanded = false },
onUpdate = { viewModel.preferRewindRewind(it) },
onUpdate = { viewModel.preferRewind(it) },
)
}
@@ -123,7 +111,7 @@ fun SeekSettingsScreen(onBack: () -> Unit) {
title = stringResource(R.string.forward_interval),
currentSeconds = preferredSeekTime.forward,
onDismissRequest = { forwardExpanded = false },
onUpdate = { viewModel.preferForwardRewind(it) },
onUpdate = { viewModel.preferForward(it) },
)
}
}

View File

@@ -33,6 +33,7 @@ import org.grakovne.lissen.R
import org.grakovne.lissen.ui.navigation.AppNavigationService
import org.grakovne.lissen.ui.screens.settings.advanced.AdvancedSettingsNavigationItemComposable
import org.grakovne.lissen.ui.screens.settings.composable.SettingsToggleItem
import org.grakovne.lissen.ui.screens.settings.composable.SettingsTopAppBar
import org.grakovne.lissen.viewmodel.SettingsViewModel
@Composable
@@ -47,24 +48,9 @@ fun CacheSettingsScreen(
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(R.string.download_settings_title),
style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
modifier = Modifier.semantics { heading() },
color = colorScheme.onSurface,
)
},
navigationIcon = {
IconButton(onClick = { onBack() }) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = stringResource(R.string.a11y_back),
tint = colorScheme.onSurface,
)
}
},
SettingsTopAppBar(
title = stringResource(R.string.download_settings_title),
onBack = onBack,
)
},
modifier =

View File

@@ -78,6 +78,7 @@ import org.grakovne.lissen.ui.components.BookCoverKey
import org.grakovne.lissen.ui.components.ImageFetcher.Companion.LocalOnlyKey
import org.grakovne.lissen.ui.components.withScrollbar
import org.grakovne.lissen.ui.extensions.withMinimumTime
import org.grakovne.lissen.ui.screens.settings.composable.SettingsTopAppBar
import org.grakovne.lissen.viewmodel.CachingModelView
import org.grakovne.lissen.viewmodel.PlayerViewModel
@@ -127,24 +128,9 @@ fun CachedItemsSettingsScreen(
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(R.string.settings_screen_cached_items_title),
style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
modifier = Modifier.semantics { heading() },
color = colorScheme.onSurface,
)
},
navigationIcon = {
IconButton(onClick = { onBack() }) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = stringResource(R.string.a11y_back),
tint = colorScheme.onSurface,
)
}
},
SettingsTopAppBar(
title = stringResource(R.string.settings_screen_cached_items_title),
onBack = onBack,
)
},
) { innerPadding ->

View File

@@ -80,8 +80,6 @@ fun ServerInfoComposable(
IconButton(
onClick = {
connectionInfoExpanded = true
// navController.showLogin()
// viewModel.logout()
},
) {
Icon(

View File

@@ -0,0 +1,45 @@
package org.grakovne.lissen.ui.screens.settings.composable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import org.grakovne.lissen.R
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun SettingsTopAppBar(
title: String,
onBack: () -> Unit,
) {
TopAppBar(
title = {
Text(
text = title,
style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
modifier = Modifier.semantics { heading() },
color = colorScheme.onSurface,
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = stringResource(R.string.a11y_back),
tint = colorScheme.onSurface,
)
}
},
)
}

View File

@@ -167,6 +167,8 @@ class CachingModelView
chapterId: String,
): Flow<Boolean> = contentCachingManager.hasMetadataCached(bookId, chapterId)
fun provideCachedChapterIds(bookId: String): Flow<List<String>> = contentCachingManager.provideCachedChapterIds(bookId)
fun fetchCachedItems() {
viewModelScope.launch {
withContext(Dispatchers.IO) {

View File

@@ -1,5 +1,9 @@
package org.grakovne.lissen.viewmodel
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
@@ -68,11 +72,9 @@ class LibraryViewModel
private val _expandedGroups = MutableStateFlow<Set<String>>(emptySet())
val expandedGroups: StateFlow<Set<String>> = _expandedGroups.asStateFlow()
private val _groupBooks = MutableStateFlow<Map<String, List<Book>>>(emptyMap())
val groupBooks: StateFlow<Map<String, List<Book>>> = _groupBooks.asStateFlow()
val groupBooks: SnapshotStateMap<String, List<Book>> = mutableStateMapOf()
private val _groupLoading = MutableStateFlow<Set<String>>(emptySet())
val groupLoading: StateFlow<Set<String>> = _groupLoading.asStateFlow()
val groupLoading: SnapshotStateList<String> = mutableStateListOf()
private val prefetchSemaphore = Semaphore(MAX_CONCURRENT_PREFETCH)
@@ -171,8 +173,8 @@ class LibraryViewModel
fun resetGroupExpansion() {
_expandedGroups.value = emptySet()
_groupBooks.value = emptyMap()
_groupLoading.value = emptySet()
groupBooks.clear()
groupLoading.clear()
}
private fun LibraryEntry.groupId(): String? =
@@ -182,7 +184,7 @@ class LibraryViewModel
is LibraryEntry.BookEntry -> null
}
private fun alreadyResolved(groupId: String): Boolean = _groupBooks.value.containsKey(groupId) || groupId in _groupLoading.value
private fun alreadyResolved(groupId: String): Boolean = groupBooks.containsKey(groupId) || groupId in groupLoading
private suspend fun fetchGroupBooks(entry: LibraryEntry) {
val groupId = entry.groupId() ?: return
@@ -192,7 +194,7 @@ class LibraryViewModel
val libraryId = preferences.getPreferredLibrary()?.id ?: return
_groupLoading.value = _groupLoading.value + groupId
groupLoading.add(groupId)
val result =
when (entry) {
is LibraryEntry.SeriesEntry -> mediaChannel.fetchSeriesItems(libraryId = libraryId, seriesId = entry.id)
@@ -207,11 +209,11 @@ class LibraryViewModel
is LibraryEntry.SeriesEntry -> books.sortedBySeriesPosition()
else -> books
}
_groupBooks.value = _groupBooks.value + (groupId to ordered)
groupBooks[groupId] = ordered
},
onFailure = { },
)
_groupLoading.value = _groupLoading.value - groupId
groupLoading.remove(groupId)
}
fun applyLinkedSearch(token: String) {

View File

@@ -94,7 +94,7 @@ class LoginViewModel
mediaChannel.startOAuth(
host = host,
onSuccess = { _loginState.value = LoginState.Idle },
onFailure = { onLoginFailure(it) },
onFailure = { _loginState.value = onLoginFailure(it) },
)
}
}
@@ -133,12 +133,7 @@ class LoginViewModel
}
}
private fun onLoginFailure(error: OperationError): LoginState.Error {
viewModelScope.launch {
_loginState.value = LoginState.Error(error)
}
return LoginState.Error(error)
}
private fun onLoginFailure(error: OperationError): LoginState.Error = LoginState.Error(error)
sealed class LoginState {
data object Idle : LoginState()

View File

@@ -352,8 +352,8 @@ class SettingsViewModel
download.saveAutoDownloadOption(option)
}
fun preferForwardRewind(seconds: Int) {
Timber.d("User action: preferForwardSkip $seconds")
fun preferForward(seconds: Int) {
Timber.d("User action: preferForward $seconds")
val current = _seekTime.value
val updated = current.copy(forward = seconds)
@@ -361,8 +361,8 @@ class SettingsViewModel
_seekTime.value = updated
}
fun preferRewindRewind(seconds: Int) {
Timber.d("User action: preferRewindSkip $seconds")
fun preferRewind(seconds: Int) {
Timber.d("User action: preferRewind $seconds")
val current = _seekTime.value
val updated = current.copy(rewind = seconds)

View File

@@ -0,0 +1,27 @@
package org.grakovne.lissen.widget
import android.content.Context
import dagger.hilt.android.EntryPointAccessors
import timber.log.Timber
suspend fun safelyRun(
playingItemId: String,
context: Context,
action: (WidgetPlaybackController) -> Unit,
) {
try {
val playbackController =
EntryPointAccessors
.fromApplication(
context = context.applicationContext,
entryPoint = WidgetPlaybackControllerEntryPoint::class.java,
).widgetPlaybackController()
when (playbackController.providePlayingItem()) {
null -> playbackController.prepareAndRun(playingItemId) { action(playbackController) }
else -> action(playbackController)
}
} catch (ex: Exception) {
Timber.w("Unable to run $action on $playingItemId due to $ex")
}
}

View File

@@ -35,13 +35,12 @@ import androidx.glance.semantics.testTag
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.media3.session.R.drawable.media3_icon_pause
import androidx.media3.session.R.drawable.media3_icon_play
import dagger.hilt.android.EntryPointAccessors
import org.grakovne.lissen.R.drawable
import org.grakovne.lissen.ui.theme.WidgetBackgroundDark
import org.grakovne.lissen.ui.theme.WidgetBackgroundLight
import org.grakovne.lissen.widget.WidgetPlaybackControllerEntryPoint
import org.grakovne.lissen.widget.bitmapFromFile
import org.grakovne.lissen.widget.bitmapFromResource
import org.grakovne.lissen.widget.safelyRun
import org.grakovne.lissen.widget.state.PlayerStateWidget
import timber.log.Timber
import java.io.File
@@ -167,21 +166,7 @@ class PlayerCoverTogglePlaybackAction : ActionCallback {
) {
val playingItemId = parameters[PlayerCoverWidget.bookIdActionKey] ?: return
try {
val playbackController =
EntryPointAccessors
.fromApplication(
context = context.applicationContext,
entryPoint = WidgetPlaybackControllerEntryPoint::class.java,
).widgetPlaybackController()
when (playbackController.providePlayingItem()) {
null -> playbackController.prepareAndRun(playingItemId) { playbackController.togglePlayPause() }
else -> playbackController.togglePlayPause()
}
} catch (ex: Exception) {
Timber.w(ex, "Unable to toggle playback from PlayerCoverWidget for %s", playingItemId)
}
safelyRun(playingItemId = playingItemId, context = context) { it.togglePlayPause() }
}
}

View File

@@ -42,13 +42,12 @@ import androidx.glance.text.FontFamily.Companion.SansSerif
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import androidx.media3.session.R
import dagger.hilt.android.EntryPointAccessors
import org.grakovne.lissen.R.drawable
import org.grakovne.lissen.ui.theme.LightBackground
import org.grakovne.lissen.ui.theme.MediumBackground
import org.grakovne.lissen.widget.WidgetPlaybackControllerEntryPoint
import org.grakovne.lissen.widget.bitmapFromFile
import org.grakovne.lissen.widget.bitmapFromResource
import org.grakovne.lissen.widget.safelyRun
import org.grakovne.lissen.widget.state.PlayerStateWidget.Companion.bookIdKey
import timber.log.Timber
import java.io.File
@@ -332,25 +331,3 @@ private fun provideAppLaunchIntent(context: Context): Intent? =
.packageManager
.getLaunchIntentForPackage(context.packageName)
?.apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP }
private suspend fun safelyRun(
playingItemId: String,
context: Context,
action: (org.grakovne.lissen.widget.WidgetPlaybackController) -> Unit,
) {
try {
val playbackController =
EntryPointAccessors
.fromApplication(
context = context.applicationContext,
entryPoint = WidgetPlaybackControllerEntryPoint::class.java,
).widgetPlaybackController()
when (playbackController.providePlayingItem()) {
null -> playbackController.prepareAndRun(playingItemId) { action(playbackController) }
else -> action(playbackController)
}
} catch (ex: Exception) {
Timber.w("Unable to run $action on $playingItemId due to $ex")
}
}

View File

@@ -200,12 +200,12 @@ class LissenMediaProviderTest {
runBlocking {
every { preferences.isForceCache() } returns true
coEvery {
localCacheRepository.searchBooks(libraryId = "l1", query = "test")
localCacheRepository.searchBooks(libraryId = "l1", query = "test", limit = 10)
} returns OperationResult.Success(emptyList())
provider.searchBooks("l1", "test", 10)
coVerify { localCacheRepository.searchBooks("l1", "test") }
coVerify { localCacheRepository.searchBooks("l1", "test", 10) }
coVerify(exactly = 0) { mediaChannel.searchBooks(any(), any(), any()) }
}

View File

@@ -78,13 +78,28 @@ class CachingSessionRegistryTest {
@Test
fun `settling a registration without status leaves nothing in progress`() {
val registry = CachingSessionRegistry()
val job = Job()
registry.register("book-1", Job())
registry.settle("book-1")
registry.register("book-1", job)
registry.settle("book-1", job)
assertFalse(registry.inProgress())
}
@Test
fun `settling a stale job does not clear the newer registration`() {
val registry = CachingSessionRegistry()
val first = Job()
val second = Job()
registry.register("book-1", first)
registry.register("book-1", second)
registry.settle("book-1", first)
assertTrue(registry.inProgress())
}
@Test
fun `first status update replaces the pending registration`() {
val registry = CachingSessionRegistry()

View File

@@ -1,8 +1,11 @@
package org.grakovne.lissen.content.cache.persistent.api
import androidx.sqlite.db.SupportSQLiteQuery
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.runBlocking
import org.grakovne.lissen.common.LibraryOrderingConfiguration
import org.grakovne.lissen.content.cache.persistent.OfflineBookStorageProperties
@@ -11,9 +14,11 @@ import org.grakovne.lissen.content.cache.persistent.converter.CachedBookEntityDe
import org.grakovne.lissen.content.cache.persistent.converter.CachedBookEntityRecentConverter
import org.grakovne.lissen.content.cache.persistent.converter.MediaProgressEntityConverter
import org.grakovne.lissen.content.cache.persistent.dao.CachedBookDao
import org.grakovne.lissen.content.cache.persistent.entity.AuthorEntry
import org.grakovne.lissen.content.cache.persistent.entity.BookEntity
import org.grakovne.lissen.content.cache.persistent.entity.GroupedEntry
import org.grakovne.lissen.domain.LibraryEntry
import org.grakovne.lissen.domain.LibraryType
import org.grakovne.lissen.persistence.preferences.LibraryPreferences
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
@@ -85,23 +90,18 @@ class CachedBookRepositoryTest {
val byId = books.associateBy { it.id }
val bySeries = books.filter { it.seriesId != null }.groupBy { it.seriesId }
coEvery { bookDao.countGroupedEntries(any()) } returns headers.size
coEvery { bookDao.countRaw(any()) } returns headers.size
coEvery { bookDao.fetchGroupedEntries(any()) } returns headers
coEvery { bookDao.fetchBooksByIds(any()) } answers { firstArg<List<String>>().mapNotNull { byId[it] } }
coEvery { bookDao.fetchBooksBySeriesIds(any()) } answers { firstArg<List<String>>().flatMap { bySeries[it].orEmpty() } }
}
private fun stubBooks(books: List<BookEntity>) {
coEvery { bookDao.countCachedBooks(libraryId = any()) } returns books.size
coEvery { bookDao.fetchCachedBooks(any()) } returns books
}
@Test
fun `empty library produces no entries`() =
runBlocking {
coEvery { bookDao.countGroupedEntries(any()) } returns 0
coEvery { bookDao.countRaw(any()) } returns 0
assertTrue(repository.fetchLibraryGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0).items.isEmpty())
assertTrue(repository.fetchLibraryGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0, libraryType = null).items.isEmpty())
}
@Test
@@ -123,7 +123,7 @@ class CachedBookRepositoryTest {
),
)
val entries = repository.fetchLibraryGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0).items
val entries = repository.fetchLibraryGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0, libraryType = null).items
assertEquals(2, entries.size)
@@ -146,7 +146,15 @@ class CachedBookRepositoryTest {
books = listOf(entity("b1", seriesId = "ser-x", seriesJson = null)),
)
val series = repository.fetchLibraryGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0).items.single() as LibraryEntry.SeriesEntry
val series =
repository
.fetchLibraryGrouped(
LIBRARY_ID,
pageSize = 20,
pageNumber = 0,
libraryType = null,
).items
.single() as LibraryEntry.SeriesEntry
assertEquals("ser-x", series.title)
}
@@ -163,7 +171,7 @@ class CachedBookRepositoryTest {
books = listOf(entity("a"), entity("b"), entity("c")),
)
val entries = repository.fetchLibraryGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0).items
val entries = repository.fetchLibraryGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0, libraryType = null).items
assertEquals(
listOf("a", "b", "c"),
entries.map { (it as LibraryEntry.BookEntry).book.id },
@@ -178,7 +186,7 @@ class CachedBookRepositoryTest {
books = (1..5).map { entity("b$it", seriesId = "ser", seriesJson = seriesJson("Series", "$it", "ser")) },
)
val page = repository.fetchLibraryGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0)
val page = repository.fetchLibraryGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0, libraryType = null)
assertEquals(1, page.totalItems)
assertEquals(1, page.items.size)
}
@@ -186,13 +194,13 @@ class CachedBookRepositoryTest {
@Test
fun `fetchSeriesItems returns only books of the requested series`() =
runBlocking {
coEvery { bookDao.fetchBooksBySeriesIds(listOf("ser-dune")) } returns
coEvery { bookDao.fetchCachedBooks(any()) } returns
listOf(
entity("b1", seriesId = "ser-dune", seriesJson = seriesJson("Dune", "1", "ser-dune")),
entity("b2", seriesId = "ser-dune", seriesJson = seriesJson("Dune", "2", "ser-dune")),
)
val books = repository.fetchSeriesItems(LIBRARY_ID, "ser-dune")
val books = repository.fetchSeriesItems(LIBRARY_ID, "ser-dune", libraryType = null)
assertEquals(listOf("b1", "b2"), books.map { it.id })
}
@@ -204,49 +212,92 @@ class CachedBookRepositoryTest {
books = (1..5).map { entity("b$it", seriesId = "ser", seriesJson = seriesJson("Series", "$it", "ser")) },
)
val series = repository.fetchLibraryGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0).items.single()
val series = repository.fetchLibraryGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0, libraryType = null).items.single()
assertInstanceOf(LibraryEntry.SeriesEntry::class.java, series)
assertEquals(listOf("b1", "b2", "b3", "b4", "b5"), (series as LibraryEntry.SeriesEntry).coverItemIds)
assertEquals(5, series.bookCount)
}
@Test
fun `groups cached books by primary author sorted by name and skips authorless books`() =
fun `maps author rows into paged author entries`() =
runBlocking {
stubBooks(
coEvery { bookDao.countRaw(any()) } returns 2
coEvery { bookDao.fetchAuthorEntries(any()) } returns
listOf(
entity("b1", author = "Frank Herbert"),
entity("b2", author = "Frank Herbert, Brian Herbert"),
entity("b3", author = "Andy Weir"),
entity("b4", author = null),
),
)
AuthorEntry(author = "Andy Weir", bookCount = 1),
AuthorEntry(author = "Frank Herbert", bookCount = 2),
)
val entries = repository.fetchAuthorsGrouped(LIBRARY_ID)
assertEquals(2, entries.size)
val page = repository.fetchAuthorsGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0, libraryType = null)
assertEquals(2, page.totalItems)
assertEquals(2, page.items.size)
val first = entries[0] as LibraryEntry.AuthorEntry
val first = page.items[0] as LibraryEntry.AuthorEntry
assertEquals("Andy Weir", first.id)
assertEquals(1, first.bookCount)
val second = entries[1] as LibraryEntry.AuthorEntry
val second = page.items[1] as LibraryEntry.AuthorEntry
assertEquals("Frank Herbert", second.id)
assertEquals(2, second.bookCount)
}
@Test
fun `fetchAuthorItems returns only the requested author's books`() =
fun `author grouping hides completed books only for library type`() =
runBlocking {
stubBooks(
every { preferences.getHideCompleted() } returns true
val queries = mutableListOf<SupportSQLiteQuery>()
coEvery { bookDao.countRaw(capture(queries)) } returns 0
repository.fetchAuthorsGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0, libraryType = LibraryType.LIBRARY)
repository.fetchAuthorsGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0, libraryType = LibraryType.PODCAST)
assertTrue(queries[0].sql.contains("isFinished"))
assertFalse(queries[1].sql.contains("isFinished"))
}
@Test
fun `empty author grouping skips the page query`() =
runBlocking {
coEvery { bookDao.countRaw(any()) } returns 0
val page = repository.fetchAuthorsGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0, libraryType = null)
assertEquals(0, page.totalItems)
assertTrue(page.items.isEmpty())
coVerify(exactly = 0) { bookDao.fetchAuthorEntries(any()) }
}
@Test
fun `fetchAuthorItems filters by author key in sql`() =
runBlocking {
val query = slot<SupportSQLiteQuery>()
coEvery { bookDao.fetchCachedBooks(capture(query)) } returns
listOf(
entity("b1", author = "Frank Herbert"),
entity("b2", author = "Andy Weir"),
entity("b3", author = "Frank Herbert, Brian Herbert"),
),
)
)
val books = repository.fetchAuthorItems(LIBRARY_ID, "Frank Herbert")
val books = repository.fetchAuthorItems(LIBRARY_ID, "Frank Herbert", libraryType = null)
assertEquals(listOf("b1", "b3"), books.map { it.id })
assertTrue(query.captured.sql.contains("WHERE libraryId = ?"))
assertTrue(query.captured.sql.contains("instr(author, ',')"))
}
@Test
fun `series and author drill-down hide completed only for library type`() =
runBlocking {
every { preferences.getHideCompleted() } returns true
val queries = mutableListOf<SupportSQLiteQuery>()
coEvery { bookDao.fetchCachedBooks(capture(queries)) } returns emptyList()
repository.fetchSeriesItems(LIBRARY_ID, "ser", libraryType = LibraryType.LIBRARY)
repository.fetchSeriesItems(LIBRARY_ID, "ser", libraryType = LibraryType.PODCAST)
repository.fetchAuthorItems(LIBRARY_ID, "author", libraryType = LibraryType.LIBRARY)
repository.fetchAuthorItems(LIBRARY_ID, "author", libraryType = LibraryType.PODCAST)
assertTrue(queries[0].sql.contains("isFinished"))
assertFalse(queries[1].sql.contains("isFinished"))
assertTrue(queries[2].sql.contains("isFinished"))
assertFalse(queries[3].sql.contains("isFinished"))
}
@Test
@@ -372,9 +423,9 @@ class CachedBookRepositoryTest {
@Test
fun `countBooks delegates to the dao`() =
runBlocking {
coEvery { bookDao.countCachedBooks(libraryId = LIBRARY_ID) } returns 3
coEvery { bookDao.countRaw(any()) } returns 3
assertEquals(3, repository.countBooks(LIBRARY_ID))
assertEquals(3, repository.countBooks(LIBRARY_ID, libraryType = null))
}
@Test
@@ -383,7 +434,7 @@ class CachedBookRepositoryTest {
every { preferences.getHideCompleted() } returns true
coEvery { bookDao.fetchCachedBooks(any()) } returns listOf(entity("b1"), entity("b2"))
val books = repository.fetchBooks(LIBRARY_ID, pageNumber = 0, pageSize = 20)
val books = repository.fetchBooks(LIBRARY_ID, pageNumber = 0, pageSize = 20, libraryType = null)
assertEquals(listOf("b1", "b2"), books.map { it.id })
}
@@ -393,7 +444,7 @@ class CachedBookRepositoryTest {
runBlocking {
coEvery { bookDao.searchBooks(any()) } returns listOf(entity("b1"))
val books = repository.searchBooks(LIBRARY_ID, "query")
val books = repository.searchBooks(LIBRARY_ID, "query", limit = 20)
assertEquals(listOf("b1"), books.map { it.id })
}
@@ -404,14 +455,15 @@ class CachedBookRepositoryTest {
val book1 = entity("b1")
val book2 = entity("b2")
coEvery { bookDao.fetchRecentlyListenedCachedBooks(libraryId = LIBRARY_ID) } returns listOf(book1, book2)
coEvery { bookDao.fetchMediaProgress("b1") } returns
org.grakovne.lissen.content.cache.persistent.entity.MediaProgressEntity(
bookId = "b1",
currentTime = 10.0,
isFinished = false,
lastUpdate = 100L,
coEvery { bookDao.fetchMediaProgress(listOf("b1", "b2")) } returns
listOf(
org.grakovne.lissen.content.cache.persistent.entity.MediaProgressEntity(
bookId = "b1",
currentTime = 10.0,
isFinished = false,
lastUpdate = 100L,
),
)
coEvery { bookDao.fetchMediaProgress("b2") } returns null
val recent1 = mockk<org.grakovne.lissen.domain.RecentBook>()
val recent2 = mockk<org.grakovne.lissen.domain.RecentBook>()
every { cachedBookEntityRecentConverter.apply(book1, 100L to 10.0) } returns recent1

View File

@@ -0,0 +1,27 @@
package org.grakovne.lissen.content.cache.persistent.api
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class OrderingSqlTest {
@Test
fun `maps every library ordering option to its own column`() {
assertEquals("title", resolveOrderField("title"))
assertEquals("author", resolveOrderField("author"))
assertEquals("createdAt", resolveOrderField("createdAt"))
assertEquals("updatedAt", resolveOrderField("updatedAt"))
}
@Test
fun `falls back to title for unknown fields`() {
assertEquals("title", resolveOrderField("duration"))
assertEquals("title", resolveOrderField("nonsense"))
}
@Test
fun `normalizes direction and rejects garbage`() {
assertEquals("ASC", resolveOrderDirection("asc"))
assertEquals("DESC", resolveOrderDirection("desc"))
assertEquals("ASC", resolveOrderDirection("sideways"))
}
}

View File

@@ -48,6 +48,12 @@ class DownloadOptionTest {
@Test
fun `unknown string produces null`() = assertNull("something_else".makeDownloadOption())
@Test
fun `malformed number_items suffix produces null instead of crashing`() {
assertNull("number_items_".makeDownloadOption())
assertNull("number_items_abc".makeDownloadOption())
}
@Test
fun `singleton options roundtrip through makeId and makeDownloadOption`() {
listOf(AllItemsDownloadOption, CurrentItemDownloadOption, RemainingItemsDownloadOption).forEach { option ->

View File

@@ -114,21 +114,21 @@ class LissenDataSourceFactoryTest {
@Test
fun `unapply returns null for non-lissen URI`() {
val uri = stubUri(scheme = "https", segments = listOf("api", "items"))
val result = unapply(uri)
val result = parseLissenUri(uri)
assertEquals(null, result)
}
@Test
fun `unapply returns null for file URI`() {
val uri = stubUri(scheme = "file", segments = listOf("data", "media_cache", "book1", "file1"))
val result = unapply(uri)
val result = parseLissenUri(uri)
assertEquals(null, result)
}
@Test
fun `unapply extracts bookId and fileId from valid lissen URI`() {
val uri = stubUri(scheme = "lissen", segments = listOf("book-abc", "file-xyz"))
val result = unapply(uri)
val result = parseLissenUri(uri)
assertEquals("book-abc" to "file-xyz", result)
}

View File

@@ -15,55 +15,55 @@ class LissenMediaSchemeConverterTest {
@Test
fun `returns null for non-lissen scheme`() {
val uri = stubUri(scheme = "https", segments = listOf("api", "items"))
assertNull(unapply(uri))
assertNull(parseLissenUri(uri))
}
@Test
fun `returns null for null scheme`() {
val uri = stubUri(scheme = null, segments = listOf("book1", "file1"))
assertNull(unapply(uri))
assertNull(parseLissenUri(uri))
}
@Test
fun `returns null for lissen URI with single segment`() {
val uri = stubUri(scheme = "lissen", segments = listOf("book1"))
assertNull(unapply(uri))
assertNull(parseLissenUri(uri))
}
@Test
fun `returns null for lissen URI with three segments`() {
val uri = stubUri(scheme = "lissen", segments = listOf("book1", "file1", "extra"))
assertNull(unapply(uri))
assertNull(parseLissenUri(uri))
}
@Test
fun `returns null for lissen URI with empty segments`() {
val uri = stubUri(scheme = "lissen", segments = listOf("", ""))
assertNull(unapply(uri))
assertNull(parseLissenUri(uri))
}
@Test
fun `returns null for lissen URI with empty first segment`() {
val uri = stubUri(scheme = "lissen", segments = listOf("", "file1"))
assertNull(unapply(uri))
assertNull(parseLissenUri(uri))
}
@Test
fun `returns null for lissen URI with empty second segment`() {
val uri = stubUri(scheme = "lissen", segments = listOf("book1", ""))
assertNull(unapply(uri))
assertNull(parseLissenUri(uri))
}
@Test
fun `returns null for empty segment list`() {
val uri = stubUri(scheme = "lissen", segments = emptyList())
assertNull(unapply(uri))
assertNull(parseLissenUri(uri))
}
@Test
fun `parses valid lissen URI`() {
val uri = stubUri(scheme = "lissen", segments = listOf("book-123", "file-456"))
val result = unapply(uri)
val result = parseLissenUri(uri)
assertNotNull(result)
assertEquals("book-123" to "file-456", result)
}
@@ -71,7 +71,7 @@ class LissenMediaSchemeConverterTest {
@Test
fun `parses lissen URI with underscore ids`() {
val uri = stubUri(scheme = "lissen", segments = listOf("li_abc123", "ino_xyz789"))
val result = unapply(uri)
val result = parseLissenUri(uri)
assertNotNull(result)
assertEquals("li_abc123", result!!.first)
assertEquals("ino_xyz789", result.second)
@@ -80,7 +80,7 @@ class LissenMediaSchemeConverterTest {
@Test
fun `parses lissen URI with numeric ids`() {
val uri = stubUri(scheme = "lissen", segments = listOf("123456", "789012"))
val result = unapply(uri)
val result = parseLissenUri(uri)
assertNotNull(result)
assertEquals("123456", result!!.first)
assertEquals("789012", result.second)

View File

@@ -208,7 +208,7 @@ class LibraryViewModelTest {
viewModel.toggleGroup(series())
assertEquals(listOf("1", "2", "22"), viewModel.groupBooks.value["ser-1"]?.map { it.id })
assertEquals(listOf("1", "2", "22"), viewModel.groupBooks["ser-1"]?.map { it.id })
}
@Test
@@ -220,7 +220,7 @@ class LibraryViewModelTest {
viewModel.toggleGroup(author())
assertTrue("aut-1" in viewModel.expandedGroups.value)
assertEquals(listOf("b1"), viewModel.groupBooks.value["aut-1"]?.map { it.id })
assertEquals(listOf("b1"), viewModel.groupBooks["aut-1"]?.map { it.id })
coVerify(exactly = 1) { mediaChannel.fetchAuthorBooks("lib-1", "aut-1") }
}
@@ -233,8 +233,8 @@ class LibraryViewModelTest {
viewModel.toggleGroup(series())
assertTrue("ser-1" in viewModel.expandedGroups.value)
assertEquals(listOf("b1", "b2"), viewModel.groupBooks.value["ser-1"]?.map { it.id })
assertTrue(viewModel.groupLoading.value.isEmpty())
assertEquals(listOf("b1", "b2"), viewModel.groupBooks["ser-1"]?.map { it.id })
assertTrue(viewModel.groupLoading.isEmpty())
}
@Test
@@ -280,7 +280,7 @@ class LibraryViewModelTest {
viewModel.prefetchGroup(series())
assertEquals(listOf("b1", "b2"), viewModel.groupBooks.value["ser-1"]?.map { it.id })
assertEquals(listOf("b1", "b2"), viewModel.groupBooks["ser-1"]?.map { it.id })
assertFalse("ser-1" in viewModel.expandedGroups.value)
}
@@ -316,8 +316,8 @@ class LibraryViewModelTest {
viewModel.resetGroupExpansion()
assertTrue(viewModel.expandedGroups.value.isEmpty())
assertTrue(viewModel.groupBooks.value.isEmpty())
assertTrue(viewModel.groupLoading.value.isEmpty())
assertTrue(viewModel.groupBooks.isEmpty())
assertTrue(viewModel.groupLoading.isEmpty())
}
}

View File

@@ -307,26 +307,26 @@ class SettingsViewModelTest {
@Nested
inner class SeekTimePreference {
@Test
fun `preferForwardRewind updates seek forward`() {
viewModel.preferForwardRewind(60)
fun `preferForward updates seek forward`() {
viewModel.preferForward(60)
assertEquals(60, viewModel.seekTime.value.forward)
}
@Test
fun `preferRewindRewind updates seek rewind`() {
viewModel.preferRewindRewind(30)
fun `preferRewind updates seek rewind`() {
viewModel.preferRewind(30)
assertEquals(30, viewModel.seekTime.value.rewind)
}
@Test
fun `preferForwardRewind preserves rewind value`() {
viewModel.preferForwardRewind(60)
fun `preferForward preserves rewind value`() {
viewModel.preferForward(60)
assertEquals(SeekTime.Default.rewind, viewModel.seekTime.value.rewind)
}
@Test
fun `preferRewindRewind preserves forward value`() {
viewModel.preferRewindRewind(10)
fun `preferRewind preserves forward value`() {
viewModel.preferRewind(10)
assertEquals(SeekTime.Default.forward, viewModel.seekTime.value.forward)
}
}