search: expose cross-library search from a home FAB

Add FTS5-backed search across songs, albums, artists and playlists,
reachable from a search FAB on the Home tab. Terms of three characters
or more run through the trigram indices; shorter terms fall back to the
instr scan, matching the per-tab filters. Results are grouped by type
and route into detail screens or song playback.
This commit is contained in:
austinried
2026-10-04 00:30:07 +00:00
parent da57ac08af
commit 5a3d5ded78
14 changed files with 985 additions and 57 deletions
@@ -703,6 +703,91 @@ class LibraryDaoTest {
assertTrue(plan, plan.contains("album_search"))
}
@Test
fun crossTypeSearchMatchesAllFourEntityTypes() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertAlbums(listOf(album(sourceId, "al-1", "Road Album", year = null, starred = null)))
dao.upsertArtists(listOf(artist(sourceId, "ar-1", "Road Artists", albumCount = 1)))
dao.upsertPlaylists(listOf(playlist(sourceId, "pl-1", "Road Trip", created = 0, changed = 0)))
dao.upsertSongs(
listOf(
song(sourceId, "s1", "Road Song", starred = null),
song(sourceId, "s2", "Neutral", starred = null).copy(artist = "Roadie"),
song(sourceId, "s3", "Unrelated", starred = null).copy(artist = "Someone"),
),
)
assertEquals(listOf("Road Album"), dao.searchAlbums(sourceId, "oad", 50).first().map { it.name })
assertEquals(listOf("ar-1"), dao.searchArtists(sourceId, "oad", 50).first().map { it.id })
assertEquals(listOf("pl-1"), dao.searchPlaylists(sourceId, "oad", 50).first().map { it.id })
assertEquals(
setOf("s1", "s2"),
dao
.searchSongs(sourceId, "oad", 50)
.first()
.map { it.song.id }
.toSet(),
)
assertTrue(dao.searchAlbums(sourceId, "", 50).first().isEmpty())
assertTrue(dao.searchSongs(sourceId, "", 50).first().isEmpty())
}
@Test
fun crossTypeSearchIsScopedToTheSource() =
runTest {
val first = source()
val second = source()
val dao = db.libraryDao()
dao.upsertAlbums(
listOf(
album(first, "a1", "Road One", year = null, starred = null),
album(second, "a2", "Road Two", year = null, starred = null),
),
)
assertEquals(listOf("Road One"), dao.searchAlbums(first, "oad", 50).first().map { it.name })
assertEquals(listOf("Road Two"), dao.searchAlbums(second, "oad", 50).first().map { it.name })
}
@Test
fun crossTypeSearchHonoursTheLimit() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertAlbums((1..3).map { album(sourceId, "a$it", "Road $it", year = null, starred = null) })
assertEquals(2, dao.searchAlbums(sourceId, "oad", 2).first().size)
}
@Test
fun songSearchRunsThroughTheTrigramIndex() =
runTest {
val plan =
plan(
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = 1 AND ${SONG_SEARCH_FILTER.replace(":search", "'abc'")} " +
"ORDER BY songs.title COLLATE NOCASE, songs.id LIMIT 20 OFFSET 0",
)
assertTrue(plan, plan.contains("song_search"))
}
@Test
fun crossTypeSearchFallsBackToAScanBelowThreeCharacters() =
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 })
}
private suspend fun plan(sql: String): String =
db.useReaderConnection { connection ->
connection.usePrepared("EXPLAIN QUERY PLAN $sql") { statement ->
@@ -110,6 +110,33 @@ class LibraryScreenTest {
assertTrue("an empty library should offer a sync", synced)
}
@Test
fun theHomeTabOffersSearch() {
var searched = false
composeRule.setContent {
SubtracksTheme {
LibraryScreen(
selectedTab = LibraryTab.Home,
onTabSelected = {},
albums = remember { flowOf(PagingData.empty<Album>()) },
artists = remember { flowOf(PagingData.empty<Artist>()) },
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
coverArt = { _, _ -> null },
onAlbumClick = {},
onArtistClick = {},
onPlaylistClick = {},
onSync = {},
onOpenSettings = {},
onOpenSearch = { searched = true },
)
}
}
composeRule.onNodeWithContentDescription("Search").performClick()
assertTrue("the home tab should expose search", searched)
}
@Test
fun theVisibleTabAndItsNeighbourCollectTheirPagingFlows() {
val collected = CopyOnWriteArrayList<String>()
@@ -0,0 +1,146 @@
package com.subtracks.ui.search
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
import com.subtracks.data.model.Album
import com.subtracks.data.model.AlbumSongItem
import com.subtracks.data.model.Artist
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.Song
import com.subtracks.ui.theme.SubtracksTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = RobolectricDeviceQualifiers.PixelXL)
class SearchScreenTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun resultsAreGroupedByTypeAndTappingASongOrAlbumFiresItsCallback() {
var played: Song? = null
var opened: Album? = null
composeRule.setContent {
SubtracksTheme {
SearchScreen(
query = "road",
onQueryChange = {},
results =
SearchResults(
albums = listOf(album("al-1", "Road Album")),
artists = listOf(artist("ar-1", "Road Artist")),
playlists = listOf(playlist("pl-1", "Road Trip")),
songs = listOf(AlbumSongItem(song("s1", "Road Song"), coverArt = null)),
),
onSongClick = { played = it },
onAlbumClick = { opened = it },
)
}
}
composeRule.onNodeWithText("Songs").assertIsDisplayed()
composeRule.onNodeWithText("Albums").assertIsDisplayed()
composeRule.onNodeWithText("Artists").assertIsDisplayed()
composeRule.onNodeWithText("Playlists").assertIsDisplayed()
composeRule.onNodeWithText("Road Song").performClick()
composeRule.onNodeWithText("Road Album").performClick()
assertEquals("s1", played?.id)
assertEquals("al-1", opened?.id)
}
@Test
fun anEmptyQueryPromptsAndAnEmptyResultSaysSo() {
composeRule.setContent {
SubtracksTheme {
SearchScreen(query = "", onQueryChange = {}, results = SearchResults())
}
}
composeRule.onNodeWithText("Search for songs, albums, artists and playlists.").assertIsDisplayed()
}
@Test
fun aQueryWithNoResultsSaysSo() {
composeRule.setContent {
SubtracksTheme {
SearchScreen(query = "zzz", onQueryChange = {}, results = SearchResults())
}
}
composeRule.onNodeWithText("No results", substring = true).assertIsDisplayed()
}
private fun album(
id: String,
name: String,
) = Album(
sourceId = 1,
id = id,
artistId = "ar-1",
name = name,
albumArtist = "Artist",
created = 0,
coverArt = null,
genre = null,
year = null,
starred = null,
songCount = 1,
)
private fun artist(
id: String,
name: String,
) = Artist(
sourceId = 1,
id = id,
name = name,
albumCount = 1,
starred = null,
coverArt = null,
)
private fun playlist(
id: String,
name: String,
) = Playlist(
sourceId = 1,
id = id,
name = name,
comment = null,
coverArt = null,
songCount = 1,
created = 0,
changed = 0,
duration = 100,
)
private fun song(
id: String,
title: String,
) = Song(
sourceId = 1,
id = id,
albumId = "al-1",
artistId = "ar-1",
title = title,
album = "Album",
artist = "Artist",
duration = 100,
track = 1,
disc = 1,
starred = null,
)
}
@@ -0,0 +1,189 @@
package com.subtracks.ui.search
import android.content.Context
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.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore
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.Song
import com.subtracks.data.prefs.fakeUserPreferences
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.NetworkServerActionSink
import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.FakePlayerConnection
import com.subtracks.playback.FakePlayerHandle
import com.subtracks.playback.PlaybackController
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.withTimeout
import okhttp3.OkHttpClient
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.concurrent.Executors
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(AndroidJUnit4::class)
class SearchViewModelTest {
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
private lateinit var db: SubtracksDatabase
private lateinit var sourceRepository: SourceRepository
private lateinit var viewModel: SearchViewModel
private val repoScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val controllerScope = CoroutineScope(SupervisorJob() + dispatcher)
@Before
fun setUp() {
Dispatchers.setMain(dispatcher)
val context = ApplicationProvider.getApplicationContext<Context>()
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
val artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), artwork, scope = repoScope)
val downloads =
DownloadRepository(
db,
sourceRepository,
FakeDownloadEngine(),
File(context.cacheDir, "downloads-${System.nanoTime()}"),
artworkStore = artwork,
artworkFetcher = { ByteArray(0) },
scope = repoScope,
)
val controller =
PlaybackController(
sourceRepository,
QueueRepository(db),
FakePlayerConnection(FakePlayerHandle()),
downloads,
scope = controllerScope,
)
viewModel =
SearchViewModel(
LibraryRepository(db, sourceRepository, NetworkServerActionSink(sourceRepository), scope = repoScope),
sourceRepository,
controller,
)
}
@After
fun tearDown() {
cancelAndJoinBlocking(controllerScope, repoScope)
db.close()
Dispatchers.resetMain()
dispatcher.close()
}
@Test
fun searchFindsEveryEntityTypeAndBlankClearsTheResults() {
runBlocking {
val id = sourceRepository.addSource("nav", "http://localhost/", "u", "p", false)
db.libraryDao().upsertAlbums(listOf(album(id, "al-1", "Road Album")))
db.libraryDao().upsertArtists(listOf(artist(id, "ar-1", "Road Artist")))
db.libraryDao().upsertPlaylists(listOf(playlist(id, "pl-1", "Road Trip")))
db.libraryDao().upsertSongs(listOf(song(id, "s1", "Road Song")))
}
viewModel.setQuery("oad")
val results = runBlocking { withTimeout(5_000) { viewModel.results.first { !it.isEmpty } } }
assertEquals(listOf("Road Album"), results.albums.map { it.name })
assertEquals(listOf("ar-1"), results.artists.map { it.id })
assertEquals(listOf("pl-1"), results.playlists.map { it.id })
assertEquals(listOf("s1"), results.songs.map { it.song.id })
viewModel.setQuery("")
val blank = runBlocking { withTimeout(5_000) { viewModel.results.first { it.isEmpty } } }
assertTrue(blank.isEmpty)
}
private fun album(
sourceId: Long,
id: String,
name: String,
) = Album(
sourceId = sourceId,
id = id,
artistId = "ar-1",
name = name,
albumArtist = "Artist",
created = 0,
coverArt = null,
genre = null,
year = null,
starred = null,
songCount = 1,
)
private fun artist(
sourceId: Long,
id: String,
name: String,
) = Artist(
sourceId = sourceId,
id = id,
name = name,
albumCount = 1,
starred = null,
coverArt = null,
)
private fun playlist(
sourceId: Long,
id: String,
name: String,
) = Playlist(
sourceId = sourceId,
id = id,
name = name,
comment = null,
coverArt = null,
songCount = 1,
created = 0,
changed = 0,
duration = 100,
)
private fun song(
sourceId: Long,
id: String,
title: String,
) = Song(
sourceId = sourceId,
id = id,
albumId = "al-1",
artistId = "ar-1",
title = title,
album = "Album",
artist = "Artist",
duration = 100,
track = 1,
disc = 1,
starred = null,
)
}