From fedba69245f8579cf76aa22524aa7f445fed37be Mon Sep 17 00:00:00 2001 From: Static Date: Fri, 2 Oct 2026 11:12:20 -0400 Subject: [PATCH 1/3] The Library shows a folder scan's books while it is open In local mode the Library loaded its list once and only looked again when a server sync record arrived, which local mode never writes. A first-run folder scan creates the folder's library row and imports its books at the very end, so a Library opened mid-scan read an empty database and kept saying "The Archive Stands Empty" until the tab was left and re-entered. The Library now watches the local catalog in Room: which folders exist and which books each holds, live or archived. A changed folder list reloads the list the way a server sync already does, and any other change re-filters the shelf. Playback progress stays out of the watch, so listening does not re-query the shelf every few seconds. Two edges are covered. Loads wait for the watch's first snapshot before reading, so a scan that lands between a load and the watch starting still arrives. A reload re-saves a library the user picked on this screen whose own settings write it cancelled, and leaves any other saved choice alone. The instrumented test sources compile again. Two fixtures had fallen behind constructor and saveAuthToken signature changes, so nothing under androidTest could build or run. Co-Authored-By: Claude Opus 5.5 --- .../HomeViewModelStartupInstrumentedTest.kt | 2 +- .../LibraryLocalCatalogLiveUpdateTest.kt | 177 ++++++++++++++++++ .../library/LibraryViewModelFilterRaceTest.kt | 24 ++- .../app/data/local/dao/AudioBookDao.kt | 9 + .../data/local/entity/LocalBookMembership.kt | 12 ++ .../data/repository/AudioBookRepository.kt | 5 + .../app/ui/library/LibraryViewModel.kt | 137 +++++++++++++- .../library/LibraryLocalCatalogChangeTest.kt | 144 ++++++++++++++ 8 files changed, 500 insertions(+), 10 deletions(-) create mode 100644 app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryLocalCatalogLiveUpdateTest.kt create mode 100644 app/src/main/java/com/ninelivesaudio/app/data/local/entity/LocalBookMembership.kt create mode 100644 app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryLocalCatalogChangeTest.kt diff --git a/app/src/androidTest/java/com/ninelivesaudio/app/ui/home/HomeViewModelStartupInstrumentedTest.kt b/app/src/androidTest/java/com/ninelivesaudio/app/ui/home/HomeViewModelStartupInstrumentedTest.kt index ef889f4b..c88df092 100644 --- a/app/src/androidTest/java/com/ninelivesaudio/app/ui/home/HomeViewModelStartupInstrumentedTest.kt +++ b/app/src/androidTest/java/com/ninelivesaudio/app/ui/home/HomeViewModelStartupInstrumentedTest.kt @@ -51,7 +51,7 @@ class HomeViewModelStartupInstrumentedTest { selectedLibraryId = FIXTURE_LIBRARY_ID, ), ) - fixtureSettings.saveAuthToken(token = "startup-fixture-token") + fixtureSettings.saveAuthToken(token = "startup-fixture-token", serverUrl = FIXTURE_SERVER_URL) instrumentation.runOnMainSync { viewModel = HomeViewModel( diff --git a/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryLocalCatalogLiveUpdateTest.kt b/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryLocalCatalogLiveUpdateTest.kt new file mode 100644 index 00000000..ad7fd83e --- /dev/null +++ b/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryLocalCatalogLiveUpdateTest.kt @@ -0,0 +1,177 @@ +package com.ninelivesaudio.app.ui.library + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.ninelivesaudio.app.NineLivesApp +import com.ninelivesaudio.app.data.local.AppDatabase +import com.ninelivesaudio.app.data.local.entity.AudioBookEntity +import com.ninelivesaudio.app.data.local.entity.LibraryEntity +import com.ninelivesaudio.app.data.repository.AudioBookRepository +import com.ninelivesaudio.app.data.repository.LibraryRepository +import com.ninelivesaudio.app.domain.model.AppMode +import com.ninelivesaudio.app.service.local.LocalFolderAccess +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * A first-run folder scan creates the folder's library row and imports its + * books only after the scan finishes. Opening the Library tab during the scan + * loaded an empty shelf, and in local mode nothing ever asked it to look + * again, so it said "The Archive Stands Empty" until the tab was re-entered. + * These drive the real [LibraryViewModel] against a real Room database and + * write rows the way the scan does, with the ViewModel already open. + */ +@RunWith(AndroidJUnit4::class) +class LibraryLocalCatalogLiveUpdateTest { + + private val app = InstrumentationRegistry.getInstrumentation() + .targetContext.applicationContext as NineLivesApp + private lateinit var database: AppDatabase + private val viewModels = ViewModelStore() + + @Before + fun setUp() { + database = Room.inMemoryDatabaseBuilder(app, AppDatabase::class.java).build() + } + + @After + fun tearDown() { + // Stop the ViewModel's collectors before the database they watch goes away. + InstrumentationRegistry.getInstrumentation().runOnMainSync { viewModels.clear() } + database.close() + } + + @Test + fun booksAFirstScanImportsWhileTheLibraryIsOpenAppearWithoutReenteringTheTab() = runBlocking { + withLocalMode { + val viewModel = openLibrary() + awaitState(viewModel, "the empty shelf opened mid-scan") { + !it.isLoading && it.libraries.isEmpty() && it.selectedLibrary == null + } + + // The scan's own order: the folder row once the folder reads, then the books. + database.libraryDao().upsert(folder) + database.audioBookDao().upsertAll((1..9).map { book("book-$it") }) + + val shown = awaitState(viewModel, "the nine scanned books") { + it.selectedLibrary?.id == folder.id && it.filteredBooks.size == 9 + } + assertEquals(9, shown.totalBookCount) + } + } + + @Test + fun aBookARescanAddsAppearsOnTheOpenShelf() = runBlocking { + database.libraryDao().upsert(folder) + database.audioBookDao().upsertAll(listOf(book("book-1"), book("book-2"))) + withLocalMode { + val viewModel = openLibrary() + awaitState(viewModel, "the two books already on the shelf") { + it.selectedLibrary?.id == folder.id && it.filteredBooks.size == 2 + } + + database.audioBookDao().upsert(book("book-3")) + + val shown = awaitState(viewModel, "the book the rescan added") { it.filteredBooks.size == 3 } + assertEquals(listOf("book-1", "book-2", "book-3"), shown.filteredBooks.map { it.id }.sorted()) + } + } + + @Test + fun aBookARescanArchivesLeavesTheOpenShelf() = runBlocking { + database.libraryDao().upsert(folder) + database.audioBookDao().upsertAll(listOf(book("book-1"), book("book-2"))) + withLocalMode { + val viewModel = openLibrary() + awaitState(viewModel, "the two books already on the shelf") { + it.selectedLibrary?.id == folder.id && it.filteredBooks.size == 2 + } + + database.audioBookDao().archiveByIds(listOf("book-2"), archivedAt = 1_727_000_000_000L) + + val shown = awaitState(viewModel, "the archived book to leave") { it.filteredBooks.size == 1 } + assertEquals(listOf("book-1"), shown.filteredBooks.map { it.id }) + } + } + + private fun openLibrary(): LibraryViewModel { + val audioBookRepository = AudioBookRepository( + context = app, + audioBookDao = database.audioBookDao(), + apiService = app.apiService, + localListeningSessionDao = database.localListeningSessionDao(), + localBookmarkDao = database.localBookmarkDao(), + playbackProgressDao = database.playbackProgressDao(), + database = database, + ) + val libraryRepository = LibraryRepository( + libraryDao = database.libraryDao(), + audioBookDao = database.audioBookDao(), + audioBookRepository = audioBookRepository, + apiService = app.apiService, + ) + val factory = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = LibraryViewModel( + libraryRepository = libraryRepository, + audioBookRepository = audioBookRepository, + apiService = app.apiService, + connectivityMonitor = app.connectivityMonitor, + settingsManager = app.settingsManager, + entitlements = app.entitlementRepository, + localFolderAccess = LocalFolderAccess(app), + ) as T + } + lateinit var viewModel: LibraryViewModel + InstrumentationRegistry.getInstrumentation().runOnMainSync { + viewModel = ViewModelProvider(viewModels, factory)[LibraryViewModel::class.java] + } + return viewModel + } + + private suspend fun withLocalMode(block: suspend () -> Unit) { + val originalSettings = app.settingsManager.currentSettings + app.settingsManager.saveSettings( + originalSettings.copy(appMode = AppMode.LOCAL, selectedLocalLibraryId = null) + ) + try { + block() + } finally { + app.settingsManager.saveSettings(originalSettings) + } + } + + private suspend fun awaitState( + viewModel: LibraryViewModel, + waitingFor: String, + predicate: (LibraryViewModel.UiState) -> Boolean, + ): LibraryViewModel.UiState = + withTimeoutOrNull(5_000) { viewModel.uiState.filter(predicate).first() } + ?: throw AssertionError( + "Timed out waiting for $waitingFor. Shelf shows " + + viewModel.uiState.value.filteredBooks.map { it.id } + + " from library " + viewModel.uiState.value.selectedLibrary?.id + ) + + private val folder = LibraryEntity(id = "audiobooks", name = "Audiobooks", isLocal = 1) + + private fun book(id: String) = AudioBookEntity( + id = id, + libraryId = folder.id, + isLocal = 1, + title = id, + author = "Author", + isDownloaded = 1, + ) +} diff --git a/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryViewModelFilterRaceTest.kt b/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryViewModelFilterRaceTest.kt index 28459029..7b7d0666 100644 --- a/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryViewModelFilterRaceTest.kt +++ b/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryViewModelFilterRaceTest.kt @@ -1,8 +1,10 @@ package com.ninelivesaudio.app.ui.library +import androidx.room.Room import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.ninelivesaudio.app.NineLivesApp +import com.ninelivesaudio.app.data.local.AppDatabase import com.ninelivesaudio.app.data.local.dao.AudioBookDao import com.ninelivesaudio.app.data.local.dao.LibraryDao import com.ninelivesaudio.app.data.local.dao.LocalBookmarkDao @@ -10,6 +12,7 @@ import com.ninelivesaudio.app.data.local.dao.LocalListeningSessionDao import com.ninelivesaudio.app.data.local.dao.PlaybackProgressDao import com.ninelivesaudio.app.data.local.entity.AudioBookEntity import com.ninelivesaudio.app.data.local.entity.LibraryEntity +import com.ninelivesaudio.app.data.local.entity.LocalBookMembership import com.ninelivesaudio.app.data.local.entity.RecentlyPlayedResult import com.ninelivesaudio.app.data.repository.AudioBookRepository import com.ninelivesaudio.app.data.repository.LibraryRepository @@ -25,6 +28,7 @@ import kotlin.coroutines.intrinsics.COROUTINE_SUSPENDED import kotlinx.coroutines.runBlocking import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.withTimeout import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -96,10 +100,13 @@ class LibraryViewModelFilterRaceTest { selectedLocalLibraryId = FixtureLibraries.alpha.id, ) ) + // Only a transaction would touch it, and these tests never import. + val database = Room.inMemoryDatabaseBuilder(app, AppDatabase::class.java).build() try { - block(LibraryViewModelFixture(app)) + block(LibraryViewModelFixture(app, database)) } finally { app.settingsManager.saveSettings(originalSettings) + database.close() } } @@ -108,7 +115,7 @@ class LibraryViewModelFilterRaceTest { } } -private class LibraryViewModelFixture(app: NineLivesApp) { +private class LibraryViewModelFixture(app: NineLivesApp, database: AppDatabase) { val dao = DelayedLibraryFilterDao() private val audioBookRepository = AudioBookRepository( context = app, @@ -117,6 +124,7 @@ private class LibraryViewModelFixture(app: NineLivesApp) { localListeningSessionDao = emptyDao(), localBookmarkDao = emptyDao(), playbackProgressDao = emptyDao(), + database = database, ) private val libraryRepository = LibraryRepository( libraryDao = localLibraryDao(), @@ -157,6 +165,7 @@ private class DelayedLibraryFilterDao { "getFilteredBooks" -> filteredBooks(args) "countByLibrary" -> 2 "getDistinctSeries", "getDistinctAuthors", "getDistinctGenresJson" -> emptyList() + "observeLocalCatalog" -> flowOf(emptyList()) "toString" -> "DelayedLibraryFilterDao" "hashCode" -> System.identityHashCode(this) "equals" -> args?.singleOrNull() === this @@ -213,10 +222,8 @@ private class DelayedLibraryFilterDao { private fun localLibraryDao(): LibraryDao = proxy { method, args -> when (method.name) { - "getLocal" -> listOf( - LibraryEntity(id = FixtureLibraries.alpha.id, name = FixtureLibraries.alpha.name, isLocal = 1), - LibraryEntity(id = FixtureLibraries.beta.id, name = FixtureLibraries.beta.name, isLocal = 1), - ) + "getLocal" -> fixtureLibraryEntities() + "observeLocal" -> flowOf(fixtureLibraryEntities()) "toString" -> "FixtureLibraryDao" "hashCode" -> System.identityHashCode(method) "equals" -> args?.singleOrNull() === method @@ -224,6 +231,11 @@ private fun localLibraryDao(): LibraryDao = proxy { method, args -> } } +private fun fixtureLibraryEntities() = listOf( + LibraryEntity(id = FixtureLibraries.alpha.id, name = FixtureLibraries.alpha.name, isLocal = 1), + LibraryEntity(id = FixtureLibraries.beta.id, name = FixtureLibraries.beta.name, isLocal = 1), +) + private inline fun emptyDao(): T = proxy { method, _ -> error("Unexpected ${T::class.simpleName} call: ${method.name}") } diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/dao/AudioBookDao.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/dao/AudioBookDao.kt index f0915868..cd5587a2 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/local/dao/AudioBookDao.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/dao/AudioBookDao.kt @@ -7,6 +7,7 @@ import androidx.room.Query import androidx.room.RawQuery import androidx.sqlite.db.SupportSQLiteQuery import com.ninelivesaudio.app.data.local.entity.AudioBookEntity +import com.ninelivesaudio.app.data.local.entity.LocalBookMembership import com.ninelivesaudio.app.data.local.entity.PlaybackProgressEntity import com.ninelivesaudio.app.data.local.entity.RecentlyPlayedResult import kotlinx.coroutines.flow.Flow @@ -95,6 +96,14 @@ interface AudioBookDao { @Query("SELECT Id FROM AudioBooks WHERE LibraryId = :libraryId AND IsLocal = 1 AND ArchivedAt IS NULL") suspend fun getLiveLocalIdsByLibrary(libraryId: String): List + /** + * Every LOCAL book's library and archived flag, re-emitted whenever the + * table changes. Progress columns stay out so a caller that drops repeats + * hears about imports and archives, not position saves. + */ + @Query("SELECT Id AS id, LibraryId AS libraryId, ArchivedAt IS NOT NULL AS isArchived FROM AudioBooks WHERE IsLocal = 1") + fun observeLocalCatalog(): Flow> + /** Ids of the archived (soft-deleted) LOCAL books in a library. */ @Query("SELECT Id FROM AudioBooks WHERE LibraryId = :libraryId AND IsLocal = 1 AND ArchivedAt IS NOT NULL") suspend fun getArchivedLocalIdsByLibrary(libraryId: String): List diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/entity/LocalBookMembership.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/LocalBookMembership.kt new file mode 100644 index 00000000..33d1c052 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/LocalBookMembership.kt @@ -0,0 +1,12 @@ +package com.ninelivesaudio.app.data.local.entity + +/** + * Result type for the local catalog query: which LOCAL book exists, in which + * library, and whether it is archived. Nothing else, so playback position + * saves leave it unchanged. + */ +data class LocalBookMembership( + val id: String, + val libraryId: String?, + val isArchived: Boolean, +) diff --git a/app/src/main/java/com/ninelivesaudio/app/data/repository/AudioBookRepository.kt b/app/src/main/java/com/ninelivesaudio/app/data/repository/AudioBookRepository.kt index 50c46498..4c60706d 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/repository/AudioBookRepository.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/repository/AudioBookRepository.kt @@ -11,6 +11,7 @@ import com.ninelivesaudio.app.data.local.dao.LocalBookmarkDao import com.ninelivesaudio.app.data.local.dao.LocalListeningSessionDao import com.ninelivesaudio.app.data.local.dao.PlaybackProgressDao import com.ninelivesaudio.app.data.local.entity.AudioBookEntity +import com.ninelivesaudio.app.data.local.entity.LocalBookMembership import com.ninelivesaudio.app.data.remote.ApiService import com.ninelivesaudio.app.data.remote.RemoteResult import com.ninelivesaudio.app.domain.model.AudioBook @@ -54,6 +55,10 @@ class AudioBookRepository @Inject constructor( fun observeLocalBooks(): Flow> = audioBookDao.observeBySource(isLocal = 1).map { entities -> entities.map { it.toDomain() } } + /** Observe which local books exist and which are archived (not their progress). */ + fun observeLocalCatalog(): Flow> = + audioBookDao.observeLocalCatalog() + /** Observe a single audiobook. */ fun observeById(id: String): Flow = audioBookDao.observeById(id).map { it?.toDomain() } diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryViewModel.kt b/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryViewModel.kt index c2564a07..616920b4 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryViewModel.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.ninelivesaudio.app.entitlement.EntitlementRepository import com.ninelivesaudio.app.entitlement.FreeTier +import com.ninelivesaudio.app.data.local.entity.LocalBookMembership import com.ninelivesaudio.app.data.remote.ApiService import com.ninelivesaudio.app.data.remote.RemoteResult import com.ninelivesaudio.app.data.remote.describeFailure @@ -116,6 +117,67 @@ internal fun shouldReloadLibrariesAfterSync( ): Boolean = (selectedLibrary == null && cachedLibraryIds.isNotEmpty()) || cachedLibraryIds.toSet() != shownLibraryIds.toSet() +/** + * Which local folders exist and which books each holds, live or archived. + * Progress stays out, or every position save during playback would + * re-query the shelf. + */ +internal data class LocalCatalogSnapshot( + val libraryIds: Set, + val books: Set, +) + +internal enum class LocalCatalogChange { NONE, REFILTER, RELOAD } + +/** + * A folder scan writes its library row and books from Settings, and LOCAL + * mode has no sync record to announce them, so a Library opened mid-scan + * stayed empty until the tab was re-entered. The ViewModel's very first + * snapshot is only a baseline, which is safe because every load waits for + * it before reading, so anything written after it arrives as a later + * snapshot. A watch that starts later (a switch into LOCAL mode) has no + * such guarantee, so its first snapshot is checked like any other. A + * changed library list (or libraries with nothing selected) reloads exactly + * as a sync does, and any other change re-filters the shelf. + */ +internal fun decideLocalCatalogChange( + previous: LocalCatalogSnapshot?, + current: LocalCatalogSnapshot, + selectedLibrary: Library?, + shownLibraryIds: List, + isLoadBaseline: Boolean, +): LocalCatalogChange = when { + previous == null && isLoadBaseline -> LocalCatalogChange.NONE + previous == current -> LocalCatalogChange.NONE + shouldReloadLibrariesAfterSync( + selectedLibrary = selectedLibrary, + shownLibraryIds = shownLibraryIds, + cachedLibraryIds = current.libraryIds.toList(), + ) -> LocalCatalogChange.RELOAD + else -> LocalCatalogChange.REFILTER +} + +/** + * The settings a catalog reload saves for a library the user picked on this + * screen whose own settings write had not finished. The reload cancels that + * write along with the rest of the load lane, and would otherwise resolve + * the older saved choice and switch the shelf back. Only an unfinished pick + * counts, so a choice saved since (Settings has its own picker) wins. + * Null when there is no such pick, it is already saved, or it is gone. + */ +internal fun settingsKeepingPendingPick( + pickedLibraryId: String?, + libraries: List, + settings: AppSettings, +): AppSettings? { + if (pickedLibraryId == null || pickedLibraryId == settings.activeLibraryId) return null + val picked = libraries.firstOrNull { it.id == pickedLibraryId } ?: return null + return when (settings.appMode) { + AppMode.LOCAL -> settings.copy(selectedLocalLibraryId = picked.id).takeIf { picked.isLocal } + AppMode.AUDIOBOOKSHELF -> settings.copy(selectedLibraryId = picked.id).takeIf { !picked.isLocal } + } +} + internal fun decideDownloadedOnlyFilter( previousStatus: ConnectionStatus, newStatus: ConnectionStatus, @@ -241,6 +303,14 @@ class LibraryViewModel @Inject constructor( // Keep them in one lane so an older operation cannot finish last. private val libraryLoadLaunch = ExclusiveLaunch() + // Completed by the catalog watch's first snapshot (null outside LOCAL + // mode). Loads wait for it, so a scan that writes between a load's reads + // and the watch's first query cannot hide inside that baseline. + private val localCatalogBaseline = CompletableDeferred() + + // A library picked on this screen whose settings write has not finished. + private var pendingLibraryPickId: String? = null + init { // Observe connectivity and auto-filter to downloaded when offline viewModelScope.launch { @@ -311,16 +381,66 @@ class LibraryViewModel @Inject constructor( } } + // LOCAL mode has no sync record, so watch the local catalog itself. + // A folder scan or rescan running in Settings lands here while the + // tab is open, not only on the next visit. + viewModelScope.launch { + var previous: LocalCatalogSnapshot? = null + observeLocalCatalogWhileLocal().collect { snapshot -> + val state = _uiState.value + val change = snapshot?.let { + decideLocalCatalogChange( + previous = previous, + current = it, + selectedLibrary = state.selectedLibrary, + shownLibraryIds = state.libraries.map { library -> library.id }, + isLoadBaseline = !localCatalogBaseline.isCompleted, + ) + } + previous = snapshot + localCatalogBaseline.complete(Unit) + when (change) { + // Same lane cleanup as the sync reload above. + LocalCatalogChange.RELOAD -> libraryLoadLaunch.launch(viewModelScope) { + loadLibrariesOwningRefresh(keepPendingPick = true) + } + LocalCatalogChange.REFILTER -> applyFilter() + LocalCatalogChange.NONE, null -> Unit + } + } + } + // Initial load libraryLoadLaunch.launch(viewModelScope) { loadLibraries() } } + /** The local catalog while in LOCAL mode, and null (no Room watch) otherwise. */ + @OptIn(ExperimentalCoroutinesApi::class) + private fun observeLocalCatalogWhileLocal(): Flow = + settingsManager.settings + .map { it.appMode == AppMode.LOCAL } + .distinctUntilChanged() + .flatMapLatest { isLocal -> + if (!isLocal) { + flowOf(null) + } else { + combine( + libraryRepository.observeLocalLibraries() + .map { libraries -> libraries.mapTo(mutableSetOf()) { it.id } }, + audioBookRepository.observeLocalCatalog().map { it.toSet() }, + ::LocalCatalogSnapshot, + ) + } + } + .distinctUntilChanged() + // ─── Loading ────────────────────────────────────────────────────────── - private suspend fun loadLibraries() { + private suspend fun loadLibraries(keepPendingPick: Boolean = false) { _uiState.update { it.copy(isLoading = true, errorMessage = null) } + localCatalogBaseline.await() try { val settings = settingsManager.currentSettings @@ -355,6 +475,15 @@ class LibraryViewModel @Inject constructor( } } + val pick = pendingLibraryPickId.takeIf { keepPendingPick } + if (settingsKeepingPendingPick(pick, libs, settingsManager.currentSettings) != null) { + settingsManager.updateSettings { latest -> + settingsKeepingPendingPick(pick, libs, latest) ?: latest + } + } + // Any pick left here came from a lane this load cancelled. It is + // saved above or, for other loads, superseded by what resolves next. + pendingLibraryPickId = null val selection = persistActiveLibrarySelection( libraries = libs, settings = settingsManager.currentSettings, @@ -511,6 +640,7 @@ class LibraryViewModel @Inject constructor( selectedLibraryFetchPersisted = false, ) } + pendingLibraryPickId = library.id libraryLoadLaunch.launch(viewModelScope) { // Persist selection so the whole app picks it up settingsManager.updateSettings { @@ -520,6 +650,7 @@ class LibraryViewModel @Inject constructor( it.copy(selectedLibraryId = library.id) } } + if (pendingLibraryPickId == library.id) pendingLibraryPickId = null // Full resync for the newly selected library loadAudioBooks(library.id) _uiState.update { it.copy(isLoading = false) } @@ -595,9 +726,9 @@ class LibraryViewModel @Inject constructor( } /** Load, then clear the refresh spinner unless a newer load took over the lane. */ - private suspend fun loadLibrariesOwningRefresh() { + private suspend fun loadLibrariesOwningRefresh(keepPendingPick: Boolean = false) { try { - loadLibraries() + loadLibraries(keepPendingPick) } finally { updateLibraryLoadStateIfActive { _uiState.update { it.copy(isRefreshing = false) } diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryLocalCatalogChangeTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryLocalCatalogChangeTest.kt new file mode 100644 index 00000000..d95759f8 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryLocalCatalogChangeTest.kt @@ -0,0 +1,144 @@ +package com.ninelivesaudio.app.ui.library + +import com.ninelivesaudio.app.data.local.entity.LocalBookMembership +import com.ninelivesaudio.app.domain.model.AppMode +import com.ninelivesaudio.app.domain.model.AppSettings +import com.ninelivesaudio.app.domain.model.Library +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * A folder scan writes its library row and books from Settings, and LOCAL + * mode has no sync record to announce them. The Library tab opened during a + * first-run scan stayed on "The Archive Stands Empty" until it was re-entered. + * It now compares each local catalog snapshot with the last one. + */ +class LibraryLocalCatalogChangeTest { + + private val audiobooks = Library(id = "audiobooks", isLocal = true) + private val podcasts = Library(id = "podcasts", isLocal = true) + private val nothing = LocalCatalogSnapshot(libraryIds = emptySet(), books = emptySet()) + private val folderOnly = LocalCatalogSnapshot(libraryIds = setOf("audiobooks"), books = emptySet()) + private val folderWithBooks = folderOnly.copy( + books = setOf(live("book-1"), live("book-2")), + ) + + @Test + fun `the first snapshot is the baseline every load waits for`() { + // Loads read only after it arrives, so it can never hold a write that + // the shelf missed. Acting on it would reload every time the tab opens. + assertEquals( + LocalCatalogChange.NONE, + decideLocalCatalogChange( + null, folderWithBooks, selectedLibrary = null, shownLibraryIds = emptyList(), isLoadBaseline = true, + ), + ) + } + + @Test + fun `a watch that starts after a switch into LOCAL mode checks its first snapshot`() { + // No load waited for this one. The shelf still shows what the server + // mode loaded, so the local folders have to be loaded now. + assertEquals( + LocalCatalogChange.RELOAD, + decideLocalCatalogChange(null, folderWithBooks, Library(id = "server"), listOf("server"), isLoadBaseline = false), + ) + } + + @Test + fun `an unchanged catalog does nothing`() { + assertEquals( + LocalCatalogChange.NONE, + decideLocalCatalogChange(folderWithBooks, folderWithBooks, audiobooks, listOf("audiobooks"), isLoadBaseline = false), + ) + } + + @Test + fun `a first scan's folder appearing reloads the library list`() { + assertEquals( + LocalCatalogChange.RELOAD, + decideLocalCatalogChange(nothing, folderOnly, selectedLibrary = null, shownLibraryIds = emptyList(), isLoadBaseline = false), + ) + } + + @Test + fun `books arriving in the shown folder refilter the shelf`() { + assertEquals( + LocalCatalogChange.REFILTER, + decideLocalCatalogChange(folderOnly, folderWithBooks, audiobooks, listOf("audiobooks"), isLoadBaseline = false), + ) + } + + @Test + fun `books arriving while nothing is selected reload the library list`() { + assertEquals( + LocalCatalogChange.RELOAD, + decideLocalCatalogChange(folderOnly, folderWithBooks, selectedLibrary = null, shownLibraryIds = emptyList(), isLoadBaseline = false), + ) + } + + @Test + fun `a book archived by a rescan refilters the shelf`() { + val archived = folderOnly.copy(books = setOf(live("book-1"), archived("book-2"))) + assertEquals( + LocalCatalogChange.REFILTER, + decideLocalCatalogChange(folderWithBooks, archived, audiobooks, listOf("audiobooks"), isLoadBaseline = false), + ) + } + + @Test + fun `a second folder being added reloads the library list`() { + val twoFolders = folderWithBooks.copy(libraryIds = setOf("audiobooks", "podcasts")) + assertEquals( + LocalCatalogChange.RELOAD, + decideLocalCatalogChange(folderWithBooks, twoFolders, audiobooks, listOf("audiobooks"), isLoadBaseline = false), + ) + } + + @Test + fun `a reload keeps a library pick whose save has not landed`() { + // The user picked podcasts, and a catalog reload cancelled that pick + // before its settings write finished. The reload saves the pick + // instead of switching the shelf back to audiobooks. + val saved = AppSettings(appMode = AppMode.LOCAL, selectedLocalLibraryId = "audiobooks") + val kept = settingsKeepingPendingPick("podcasts", listOf(audiobooks, podcasts), saved) + assertEquals("podcasts", kept?.selectedLocalLibraryId) + } + + @Test + fun `a reload with the pick already saved writes nothing`() { + val saved = AppSettings(appMode = AppMode.LOCAL, selectedLocalLibraryId = "audiobooks") + assertNull(settingsKeepingPendingPick("audiobooks", listOf(audiobooks, podcasts), saved)) + } + + @Test + fun `a reload does not keep a library that is gone`() { + val saved = AppSettings(appMode = AppMode.LOCAL, selectedLocalLibraryId = "audiobooks") + assertNull(settingsKeepingPendingPick("podcasts", listOf(audiobooks), saved)) + } + + @Test + fun `a reload with no unfinished pick leaves the saved choice alone`() { + // Settings saved podcasts since the Library last picked anything. + val saved = AppSettings(appMode = AppMode.LOCAL, selectedLocalLibraryId = "podcasts") + assertNull(settingsKeepingPendingPick(null, listOf(audiobooks, podcasts), saved)) + } + + @Test + fun `a reload with nothing picked leaves the saved choice to the normal rules`() { + val saved = AppSettings(appMode = AppMode.LOCAL, selectedLocalLibraryId = null) + assertNull(settingsKeepingPendingPick(null, listOf(audiobooks), saved)) + } + + @Test + fun `a reload never saves a server library as the local choice`() { + val server = Library(id = "server", isLocal = false) + val saved = AppSettings(appMode = AppMode.LOCAL, selectedLocalLibraryId = "audiobooks") + assertNull(settingsKeepingPendingPick("server", listOf(audiobooks, server), saved)) + } + + private fun live(id: String) = LocalBookMembership(id = id, libraryId = "audiobooks", isArchived = false) + + private fun archived(id: String) = LocalBookMembership(id = id, libraryId = "audiobooks", isArchived = true) +} From 3004cdfbb215a175646d2706c3cddb3fc79ecf6a Mon Sep 17 00:00:00 2001 From: Static Date: Fri, 2 Oct 2026 11:21:40 -0400 Subject: [PATCH 2/3] A rescan that retags a book updates the open shelf The catalog watch only compared which books existed and whether they were archived. A rescan that re-reads the same file with a new title, author, cover, or duration keeps the id, the folder, and the archive state, so the open shelf kept the old details until it was reopened. The watched row (now LocalCatalogEntry) also carries the fields the shelf shows, searches, sorts, and groups on. Progress and position still stay out, so playback saves do not re-query the shelf. Co-Authored-By: Claude Opus 5.5 --- .../LibraryLocalCatalogLiveUpdateTest.kt | 20 ++++++++++++++++ .../library/LibraryViewModelFilterRaceTest.kt | 4 ++-- .../app/data/local/dao/AudioBookDao.kt | 22 +++++++++++++----- .../data/local/entity/LocalBookMembership.kt | 12 ---------- .../data/local/entity/LocalCatalogEntry.kt | 23 +++++++++++++++++++ .../data/repository/AudioBookRepository.kt | 6 ++--- .../app/ui/library/LibraryViewModel.kt | 10 ++++---- .../library/LibraryLocalCatalogChangeTest.kt | 15 +++++++++--- 8 files changed, 81 insertions(+), 31 deletions(-) delete mode 100644 app/src/main/java/com/ninelivesaudio/app/data/local/entity/LocalBookMembership.kt create mode 100644 app/src/main/java/com/ninelivesaudio/app/data/local/entity/LocalCatalogEntry.kt diff --git a/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryLocalCatalogLiveUpdateTest.kt b/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryLocalCatalogLiveUpdateTest.kt index ad7fd83e..27d06683 100644 --- a/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryLocalCatalogLiveUpdateTest.kt +++ b/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryLocalCatalogLiveUpdateTest.kt @@ -105,6 +105,26 @@ class LibraryLocalCatalogLiveUpdateTest { } } + @Test + fun aRescanThatRetitlesABookUpdatesTheOpenShelf() = runBlocking { + database.libraryDao().upsert(folder) + database.audioBookDao().upsertAll(listOf(book("book-1"), book("book-2"))) + withLocalMode { + val viewModel = openLibrary() + awaitState(viewModel, "the two books already on the shelf") { + it.selectedLibrary?.id == folder.id && it.filteredBooks.size == 2 + } + + // Same file, same folder, new tags: only what the shelf shows changes. + database.audioBookDao().upsert(book("book-2").copy(title = "Retagged Title")) + + val shown = awaitState(viewModel, "the rescanned title") { state -> + state.filteredBooks.any { it.title == "Retagged Title" } + } + assertEquals(2, shown.filteredBooks.size) + } + } + private fun openLibrary(): LibraryViewModel { val audioBookRepository = AudioBookRepository( context = app, diff --git a/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryViewModelFilterRaceTest.kt b/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryViewModelFilterRaceTest.kt index 7b7d0666..9884b77a 100644 --- a/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryViewModelFilterRaceTest.kt +++ b/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryViewModelFilterRaceTest.kt @@ -12,7 +12,7 @@ import com.ninelivesaudio.app.data.local.dao.LocalListeningSessionDao import com.ninelivesaudio.app.data.local.dao.PlaybackProgressDao import com.ninelivesaudio.app.data.local.entity.AudioBookEntity import com.ninelivesaudio.app.data.local.entity.LibraryEntity -import com.ninelivesaudio.app.data.local.entity.LocalBookMembership +import com.ninelivesaudio.app.data.local.entity.LocalCatalogEntry import com.ninelivesaudio.app.data.local.entity.RecentlyPlayedResult import com.ninelivesaudio.app.data.repository.AudioBookRepository import com.ninelivesaudio.app.data.repository.LibraryRepository @@ -165,7 +165,7 @@ private class DelayedLibraryFilterDao { "getFilteredBooks" -> filteredBooks(args) "countByLibrary" -> 2 "getDistinctSeries", "getDistinctAuthors", "getDistinctGenresJson" -> emptyList() - "observeLocalCatalog" -> flowOf(emptyList()) + "observeLocalCatalog" -> flowOf(emptyList()) "toString" -> "DelayedLibraryFilterDao" "hashCode" -> System.identityHashCode(this) "equals" -> args?.singleOrNull() === this diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/dao/AudioBookDao.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/dao/AudioBookDao.kt index cd5587a2..d24a0b4c 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/local/dao/AudioBookDao.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/dao/AudioBookDao.kt @@ -7,7 +7,7 @@ import androidx.room.Query import androidx.room.RawQuery import androidx.sqlite.db.SupportSQLiteQuery import com.ninelivesaudio.app.data.local.entity.AudioBookEntity -import com.ninelivesaudio.app.data.local.entity.LocalBookMembership +import com.ninelivesaudio.app.data.local.entity.LocalCatalogEntry import com.ninelivesaudio.app.data.local.entity.PlaybackProgressEntity import com.ninelivesaudio.app.data.local.entity.RecentlyPlayedResult import kotlinx.coroutines.flow.Flow @@ -97,12 +97,22 @@ interface AudioBookDao { suspend fun getLiveLocalIdsByLibrary(libraryId: String): List /** - * Every LOCAL book's library and archived flag, re-emitted whenever the - * table changes. Progress columns stay out so a caller that drops repeats - * hears about imports and archives, not position saves. + * Every LOCAL book's library, archived flag, and shelf fields, re-emitted + * whenever the table changes. Progress columns stay out so a caller that + * drops repeats hears about imports, archives, and rescans, not position + * saves. */ - @Query("SELECT Id AS id, LibraryId AS libraryId, ArchivedAt IS NOT NULL AS isArchived FROM AudioBooks WHERE IsLocal = 1") - fun observeLocalCatalog(): Flow> + @Query( + """ + SELECT Id AS id, LibraryId AS libraryId, ArchivedAt IS NOT NULL AS isArchived, + Title AS title, Author AS author, Narrator AS narrator, + CoverPath AS coverPath, LocalCoverPath AS localCoverPath, + DurationSeconds AS durationSeconds, AddedAt AS addedAt, + SeriesName AS seriesName, SeriesSequence AS seriesSequence, GenresJson AS genresJson + FROM AudioBooks WHERE IsLocal = 1 + """ + ) + fun observeLocalCatalog(): Flow> /** Ids of the archived (soft-deleted) LOCAL books in a library. */ @Query("SELECT Id FROM AudioBooks WHERE LibraryId = :libraryId AND IsLocal = 1 AND ArchivedAt IS NOT NULL") diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/entity/LocalBookMembership.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/LocalBookMembership.kt deleted file mode 100644 index 33d1c052..00000000 --- a/app/src/main/java/com/ninelivesaudio/app/data/local/entity/LocalBookMembership.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.ninelivesaudio.app.data.local.entity - -/** - * Result type for the local catalog query: which LOCAL book exists, in which - * library, and whether it is archived. Nothing else, so playback position - * saves leave it unchanged. - */ -data class LocalBookMembership( - val id: String, - val libraryId: String?, - val isArchived: Boolean, -) diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/entity/LocalCatalogEntry.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/LocalCatalogEntry.kt new file mode 100644 index 00000000..1fb8f009 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/LocalCatalogEntry.kt @@ -0,0 +1,23 @@ +package com.ninelivesaudio.app.data.local.entity + +/** + * Result type for the local catalog query: which LOCAL book exists, in which + * library, whether it is archived, and the fields the Library shelf shows, + * searches, sorts, or groups on. Progress and position stay out, so playback + * saves leave it unchanged while a rescan that retags a book does not. + */ +data class LocalCatalogEntry( + val id: String, + val libraryId: String?, + val isArchived: Boolean, + val title: String = "", + val author: String? = null, + val narrator: String? = null, + val coverPath: String? = null, + val localCoverPath: String? = null, + val durationSeconds: Double = 0.0, + val addedAt: String? = null, + val seriesName: String? = null, + val seriesSequence: String? = null, + val genresJson: String? = null, +) diff --git a/app/src/main/java/com/ninelivesaudio/app/data/repository/AudioBookRepository.kt b/app/src/main/java/com/ninelivesaudio/app/data/repository/AudioBookRepository.kt index 4c60706d..f75ccc5b 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/repository/AudioBookRepository.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/repository/AudioBookRepository.kt @@ -11,7 +11,7 @@ import com.ninelivesaudio.app.data.local.dao.LocalBookmarkDao import com.ninelivesaudio.app.data.local.dao.LocalListeningSessionDao import com.ninelivesaudio.app.data.local.dao.PlaybackProgressDao import com.ninelivesaudio.app.data.local.entity.AudioBookEntity -import com.ninelivesaudio.app.data.local.entity.LocalBookMembership +import com.ninelivesaudio.app.data.local.entity.LocalCatalogEntry import com.ninelivesaudio.app.data.remote.ApiService import com.ninelivesaudio.app.data.remote.RemoteResult import com.ninelivesaudio.app.domain.model.AudioBook @@ -55,8 +55,8 @@ class AudioBookRepository @Inject constructor( fun observeLocalBooks(): Flow> = audioBookDao.observeBySource(isLocal = 1).map { entities -> entities.map { it.toDomain() } } - /** Observe which local books exist and which are archived (not their progress). */ - fun observeLocalCatalog(): Flow> = + /** Observe which local books exist, which are archived, and their shelf fields (not progress). */ + fun observeLocalCatalog(): Flow> = audioBookDao.observeLocalCatalog() /** Observe a single audiobook. */ diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryViewModel.kt b/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryViewModel.kt index 616920b4..bbfafdf3 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryViewModel.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryViewModel.kt @@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.ninelivesaudio.app.entitlement.EntitlementRepository import com.ninelivesaudio.app.entitlement.FreeTier -import com.ninelivesaudio.app.data.local.entity.LocalBookMembership +import com.ninelivesaudio.app.data.local.entity.LocalCatalogEntry import com.ninelivesaudio.app.data.remote.ApiService import com.ninelivesaudio.app.data.remote.RemoteResult import com.ninelivesaudio.app.data.remote.describeFailure @@ -118,13 +118,13 @@ internal fun shouldReloadLibrariesAfterSync( cachedLibraryIds.toSet() != shownLibraryIds.toSet() /** - * Which local folders exist and which books each holds, live or archived. - * Progress stays out, or every position save during playback would - * re-query the shelf. + * Which local folders exist, which books each holds (live or archived), and + * what the shelf shows for each. Progress stays out, or every position save + * during playback would re-query the shelf. */ internal data class LocalCatalogSnapshot( val libraryIds: Set, - val books: Set, + val books: Set, ) internal enum class LocalCatalogChange { NONE, REFILTER, RELOAD } diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryLocalCatalogChangeTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryLocalCatalogChangeTest.kt index d95759f8..07d5b1a5 100644 --- a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryLocalCatalogChangeTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryLocalCatalogChangeTest.kt @@ -1,6 +1,6 @@ package com.ninelivesaudio.app.ui.library -import com.ninelivesaudio.app.data.local.entity.LocalBookMembership +import com.ninelivesaudio.app.data.local.entity.LocalCatalogEntry import com.ninelivesaudio.app.domain.model.AppMode import com.ninelivesaudio.app.domain.model.AppSettings import com.ninelivesaudio.app.domain.model.Library @@ -87,6 +87,15 @@ class LibraryLocalCatalogChangeTest { ) } + @Test + fun `a rescan that retags a book refilters the shelf`() { + val retagged = folderOnly.copy(books = setOf(live("book-1"), live("book-2").copy(title = "Retagged"))) + assertEquals( + LocalCatalogChange.REFILTER, + decideLocalCatalogChange(folderWithBooks, retagged, audiobooks, listOf("audiobooks"), isLoadBaseline = false), + ) + } + @Test fun `a second folder being added reloads the library list`() { val twoFolders = folderWithBooks.copy(libraryIds = setOf("audiobooks", "podcasts")) @@ -138,7 +147,7 @@ class LibraryLocalCatalogChangeTest { assertNull(settingsKeepingPendingPick("server", listOf(audiobooks, server), saved)) } - private fun live(id: String) = LocalBookMembership(id = id, libraryId = "audiobooks", isArchived = false) + private fun live(id: String) = LocalCatalogEntry(id = id, libraryId = "audiobooks", isArchived = false) - private fun archived(id: String) = LocalBookMembership(id = id, libraryId = "audiobooks", isArchived = true) + private fun archived(id: String) = LocalCatalogEntry(id = id, libraryId = "audiobooks", isArchived = true) } From 86e2617a43df7570519f1a3a594d386ec0370991 Mon Sep 17 00:00:00 2001 From: Static Date: Fri, 2 Oct 2026 11:28:24 -0400 Subject: [PATCH 3/3] A rescan that re-splits a book's chapters updates the open shelf Library cards show "Ch N/M", and a rescan can change the track layout without changing the title or total duration, so the open card kept the old chapter count. The watched row now carries the length of the chapter list. The length stands in for the list itself, which can be large, so playback saves still do not read every chapter blob. Co-Authored-By: Claude Opus 5.5 --- .../LibraryLocalCatalogLiveUpdateTest.kt | 20 +++++++++++++++++++ .../app/data/local/dao/AudioBookDao.kt | 3 ++- .../data/local/entity/LocalCatalogEntry.kt | 4 ++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryLocalCatalogLiveUpdateTest.kt b/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryLocalCatalogLiveUpdateTest.kt index 27d06683..aea81e05 100644 --- a/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryLocalCatalogLiveUpdateTest.kt +++ b/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryLocalCatalogLiveUpdateTest.kt @@ -125,6 +125,26 @@ class LibraryLocalCatalogLiveUpdateTest { } } + @Test + fun aRescanThatSplitsAChapterUpdatesTheOpenShelf() = runBlocking { + val oneChapter = """[{"Id":0,"Start":0.0,"End":600.0,"Title":"01"}]""" + val twoChapters = """[{"Id":0,"Start":0.0,"End":300.0,"Title":"01"},""" + + """{"Id":1,"Start":300.0,"End":600.0,"Title":"02"}]""" + database.libraryDao().upsert(folder) + database.audioBookDao().upsertAll(listOf(book("book-1").copy(durationSeconds = 600.0, chaptersJson = oneChapter))) + withLocalMode { + val viewModel = openLibrary() + awaitState(viewModel, "the one-chapter book on the shelf") { + it.filteredBooks.singleOrNull()?.chapters?.size == 1 + } + + // Same file length, new track layout: the card's "Ch 1/N" count changes. + database.audioBookDao().upsert(book("book-1").copy(durationSeconds = 600.0, chaptersJson = twoChapters)) + + awaitState(viewModel, "the split chapters") { it.filteredBooks.singleOrNull()?.chapters?.size == 2 } + } + } + private fun openLibrary(): LibraryViewModel { val audioBookRepository = AudioBookRepository( context = app, diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/dao/AudioBookDao.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/dao/AudioBookDao.kt index d24a0b4c..2ca43ad0 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/local/dao/AudioBookDao.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/dao/AudioBookDao.kt @@ -108,7 +108,8 @@ interface AudioBookDao { Title AS title, Author AS author, Narrator AS narrator, CoverPath AS coverPath, LocalCoverPath AS localCoverPath, DurationSeconds AS durationSeconds, AddedAt AS addedAt, - SeriesName AS seriesName, SeriesSequence AS seriesSequence, GenresJson AS genresJson + SeriesName AS seriesName, SeriesSequence AS seriesSequence, GenresJson AS genresJson, + length(ChaptersJson) AS chaptersJsonLength FROM AudioBooks WHERE IsLocal = 1 """ ) diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/entity/LocalCatalogEntry.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/LocalCatalogEntry.kt index 1fb8f009..4114b56f 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/local/entity/LocalCatalogEntry.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/LocalCatalogEntry.kt @@ -20,4 +20,8 @@ data class LocalCatalogEntry( val seriesName: String? = null, val seriesSequence: String? = null, val genresJson: String? = null, + // The card shows "Ch N/M". The length stands in for the chapter list, which + // can be large, so a re-split track layout still changes the entry without + // reading every chapter blob on each playback save. + val chaptersJsonLength: Int? = null, )