diff --git a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt index 14b322f0..31af298e 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt @@ -128,33 +128,23 @@ internal const val PLAYLIST_ORDER_BY_ADDED_REVERSED = "created ASC, name COLLATE internal const val PLAYLIST_ORDER_BY_UPDATED = "changed DESC, name COLLATE NOCASE, id" internal const val PLAYLIST_ORDER_BY_UPDATED_REVERSED = "changed ASC, name COLLATE NOCASE DESC, id DESC" -// Cross-type search: a three-character-or-longer query runs through the trigram index and anything -// shorter falls back to the instr scan, mirroring the per-tab filters above. +// Cross-type search only runs from three characters, since a shorter term cannot use the trigram +// index and scanning the whole library for it is not worth it. internal const val ALBUM_SEARCH_FILTER = - "(:search <> '' AND (" + - "(length(:search) >= 3 AND rowid IN (SELECT rowid FROM album_search " + - "WHERE album_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " + - "OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " + - "OR instr(lower(albumArtist), lower(:search)) > 0))))" + "(length(:search) >= 3 AND rowid IN (SELECT rowid FROM album_search " + + "WHERE album_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"'))" internal const val ARTIST_SEARCH_FILTER = - "(:search <> '' AND (" + - "(length(:search) >= 3 AND rowid IN (SELECT rowid FROM artist_search " + - "WHERE artist_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " + - "OR (length(:search) < 3 AND instr(lower(name), lower(:search)) > 0)))" + "(length(:search) >= 3 AND rowid IN (SELECT rowid FROM artist_search " + + "WHERE artist_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"'))" internal const val PLAYLIST_SEARCH_FILTER = - "(:search <> '' AND (" + - "(length(:search) >= 3 AND rowid IN (SELECT rowid FROM playlist_search " + - "WHERE playlist_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " + - "OR (length(:search) < 3 AND instr(lower(name), lower(:search)) > 0)))" + "(length(:search) >= 3 AND rowid IN (SELECT rowid FROM playlist_search " + + "WHERE playlist_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"'))" internal const val SONG_SEARCH_FILTER = - "(:search <> '' AND (" + - "(length(:search) >= 3 AND songs.rowid IN (SELECT rowid FROM song_search " + - "WHERE song_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " + - "OR (length(:search) < 3 AND (instr(lower(songs.title), lower(:search)) > 0 " + - "OR instr(lower(songs.artist), lower(:search)) > 0))))" + "(length(:search) >= 3 AND songs.rowid IN (SELECT rowid FROM song_search " + + "WHERE song_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"'))" @Dao @DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class) diff --git a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt index 694ceaa0..a9b1f3bf 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt @@ -510,4 +510,6 @@ const val REDISCOVER_WINDOW_MS = 60L * 24 * 60 * 60 * 1000 const val SEARCH_RESULT_LIMIT = 50 +const val SEARCH_MIN_LENGTH = 3 + fun rediscoverCutoff(): Long = System.currentTimeMillis() - REDISCOVER_WINDOW_MS diff --git a/app/src/main/kotlin/com/subtracks/ui/search/SearchScreen.kt b/app/src/main/kotlin/com/subtracks/ui/search/SearchScreen.kt index 10a10ce0..ac77a2e2 100644 --- a/app/src/main/kotlin/com/subtracks/ui/search/SearchScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/search/SearchScreen.kt @@ -37,6 +37,7 @@ import com.subtracks.data.model.Artist import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.Playlist import com.subtracks.data.model.Song +import com.subtracks.data.repo.SEARCH_MIN_LENGTH import com.subtracks.data.source.StarType import com.subtracks.playback.PlaybackController import com.subtracks.ui.components.ContextMenuHost @@ -144,9 +145,9 @@ fun SearchScreen( }, ) { padding -> when { - query.isBlank() -> { + query.length < SEARCH_MIN_LENGTH -> { EmptyState( - text = "Search for songs, albums, artists and playlists.", + text = "Type at least $SEARCH_MIN_LENGTH characters to search.", modifier = Modifier.padding(padding), ) } diff --git a/app/src/main/kotlin/com/subtracks/ui/search/SearchViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/search/SearchViewModel.kt index daf0d8a9..2ac73795 100644 --- a/app/src/main/kotlin/com/subtracks/ui/search/SearchViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/search/SearchViewModel.kt @@ -9,6 +9,7 @@ import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.Playlist import com.subtracks.data.model.Song import com.subtracks.data.repo.LibraryRepository +import com.subtracks.data.repo.SEARCH_MIN_LENGTH import com.subtracks.data.repo.SourceRepository import com.subtracks.playback.PlaybackController import com.subtracks.ui.home.playSongInContext @@ -45,7 +46,7 @@ class SearchViewModel( libraryRepository.activeSourceId .flatMapLatest { sourceId -> _query.flatMapLatest { text -> - if (sourceId == null || text.isBlank()) { + if (sourceId == null || text.length < SEARCH_MIN_LENGTH) { flowOf(SearchResults()) } else { combine( diff --git a/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt b/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt index c3658cac..2b5beda3 100644 --- a/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt @@ -777,15 +777,15 @@ class LibraryDaoTest { } @Test - fun crossTypeSearchFallsBackToAScanBelowThreeCharacters() = + fun crossTypeSearchIgnoresQueriesShorterThanThreeCharacters() = runTest { val sourceId = source() val dao = db.libraryDao() dao.upsertAlbums(listOf(album(sourceId, "al-1", "Gamma", year = null, starred = null))) dao.upsertSongs(listOf(song(sourceId, "s1", "Gamma Ray", starred = null))) - assertEquals(listOf("Gamma"), dao.searchAlbums(sourceId, "ga", 50).first().map { it.name }) - assertEquals(listOf("s1"), dao.searchSongs(sourceId, "ga", 50).first().map { it.song.id }) + assertTrue(dao.searchAlbums(sourceId, "ga", 50).first().isEmpty()) + assertTrue(dao.searchSongs(sourceId, "ga", 50).first().isEmpty()) } private suspend fun plan(sql: String): String = diff --git a/app/src/test/kotlin/com/subtracks/ui/search/SearchScreenTest.kt b/app/src/test/kotlin/com/subtracks/ui/search/SearchScreenTest.kt index d21e4bcc..81d59ca5 100644 --- a/app/src/test/kotlin/com/subtracks/ui/search/SearchScreenTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/search/SearchScreenTest.kt @@ -62,14 +62,14 @@ class SearchScreenTest { } @Test - fun anEmptyQueryPromptsAndAnEmptyResultSaysSo() { + fun aQueryShorterThanThreeCharactersPromptsForMoreInput() { composeRule.setContent { SubtracksTheme { - SearchScreen(query = "", onQueryChange = {}, results = SearchResults()) + SearchScreen(query = "ro", onQueryChange = {}, results = SearchResults()) } } - composeRule.onNodeWithText("Search for songs, albums, artists and playlists.").assertIsDisplayed() + composeRule.onNodeWithText("Type at least 3 characters to search.").assertIsDisplayed() } @Test diff --git a/app/src/test/kotlin/com/subtracks/ui/search/SearchViewModelTest.kt b/app/src/test/kotlin/com/subtracks/ui/search/SearchViewModelTest.kt index 37edb43d..a6aba47b 100644 --- a/app/src/test/kotlin/com/subtracks/ui/search/SearchViewModelTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/search/SearchViewModelTest.kt @@ -5,6 +5,7 @@ import androidx.room3.Room import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.subtracks.awaitUntil import com.subtracks.cancelAndJoinBlocking import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.download.ArtworkStore @@ -12,6 +13,7 @@ import com.subtracks.data.download.FakeDownloadEngine import com.subtracks.data.model.Album import com.subtracks.data.model.Artist import com.subtracks.data.model.Playlist +import com.subtracks.data.model.QueueKind import com.subtracks.data.model.Song import com.subtracks.data.prefs.fakeUserPreferences import com.subtracks.data.repo.DownloadRepository @@ -116,12 +118,41 @@ class SearchViewModelTest { assertEquals(listOf("pl-1"), results.playlists.map { it.id }) assertEquals(listOf("s1"), results.songs.map { it.song.id }) + viewModel.setQuery("oa") + val short = runBlocking { withTimeout(5_000) { viewModel.results.first { it.isEmpty } } } + + assertTrue(short.isEmpty) + viewModel.setQuery("") val blank = runBlocking { withTimeout(5_000) { viewModel.results.first { it.isEmpty } } } assertTrue(blank.isEmpty) } + @Test + fun playingASearchResultPlaysItsAlbumInsteadOfASearchQueue() { + val sourceId = + runBlocking { + val id = sourceRepository.addSource("nav", "http://localhost/", "u", "p", false) + db.libraryDao().upsertAlbums(listOf(album(id, "al-1", "Road Album"))) + db.libraryDao().upsertSongs( + listOf( + song(id, "s1", "Road Song"), + song(id, "s2", "Another").copy(track = 2), + ), + ) + id + } + + viewModel.play(song(sourceId, "s1", "Road Song")) + awaitUntil("the queue is built") { runBlocking { db.queueDao().entries().isNotEmpty() } } + + val entries = runBlocking { db.queueDao().entries() } + assertEquals(1, entries.size) + assertEquals(QueueKind.Album, entries.first().kind) + assertEquals("al-1", entries.first().refId) + } + private fun album( sourceId: Long, id: String,