feat(search): add Jellyfin-backed search

This commit is contained in:
2026-05-02 17:40:56 +02:00
parent 36e89f35d9
commit 6433225014
7 changed files with 208 additions and 96 deletions

View File

@@ -113,6 +113,7 @@ fun SearchFullScreen(
onResultClick = viewModel::onSearchResultSelected, onResultClick = viewModel::onSearchResultSelected,
onGenreSelected = { genre -> onGenreSelected = { genre ->
selectedGenreName = genre.name.takeIf { it != selectedGenreName } selectedGenreName = genre.name.takeIf { it != selectedGenreName }
viewModel.setSelectedGenre(selectedGenreName)
}, },
modifier = modifier.then(sharedBoundsModifier) modifier = modifier.then(sharedBoundsModifier)
) )
@@ -158,22 +159,45 @@ private fun SearchFullScreenContent(
selectedGenreName = selectedGenreName, selectedGenreName = selectedGenreName,
onGenreSelected = onGenreSelected onGenreSelected = onGenreSelected
) )
if (selectedGenreName != null) {
Spacer(modifier = Modifier.height(30.dp))
SectionTitle(text = "Search Results")
Spacer(modifier = Modifier.height(16.dp))
SearchResults(
searchResults = searchResults,
onResultClick = onResultClick
)
}
} else { } else {
SectionTitle(text = "Search Results") SectionTitle(text = "Search Results")
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
if (searchResults.isEmpty()) { SearchResults(
SearchMessage( searchResults = searchResults,
title = "No matches",
body = "Try a different title or browse your libraries."
)
} else {
SearchResultsGrid(
results = searchResults,
onResultClick = onResultClick onResultClick = onResultClick
) )
} }
} }
} }
@Composable
private fun SearchResults(
searchResults: List<SearchResult>,
onResultClick: (SearchResult) -> Unit,
modifier: Modifier = Modifier
) {
if (searchResults.isEmpty()) {
SearchMessage(
title = "No matches",
body = "Try a different title or browse your libraries.",
modifier = modifier
)
} else {
SearchResultsGrid(
results = searchResults,
onResultClick = onResultClick,
modifier = modifier
)
}
} }
@Composable @Composable

View File

@@ -1,14 +1,18 @@
package hu.bbara.purefin.ui.screen.home.components.search package hu.bbara.purefin.ui.screen.home.components.search
import androidx.compose.foundation.clickable import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -17,8 +21,8 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.model.MediaKind
import hu.bbara.purefin.feature.search.SearchResult import hu.bbara.purefin.feature.search.SearchResult
import hu.bbara.purefin.model.MediaKind
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
@Composable @Composable
@@ -29,43 +33,50 @@ internal fun SearchResultCard(
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
Surface( Card(
shape = RoundedCornerShape(22.dp), onClick = onClick,
color = scheme.surfaceContainer, shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = scheme.surfaceContainer),
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(22.dp))
.clickable(onClick = onClick)
) { ) {
Column( Column(modifier = Modifier.fillMaxWidth()) {
verticalArrangement = Arrangement.spacedBy(10.dp), Box(
modifier = Modifier.padding(12.dp) modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 10f)
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
.background(scheme.surface)
) { ) {
PurefinAsyncImage( PurefinAsyncImage(
model = item.posterUrl, model = item.posterUrl,
contentDescription = item.title, contentDescription = item.title,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier modifier = Modifier.fillMaxSize()
.fillMaxWidth()
.height(180.dp)
.clip(RoundedCornerShape(18.dp))
) )
}
Spacer(modifier = Modifier.height(10.dp))
Column(modifier = Modifier.padding(12.dp)) {
Text( Text(
text = item.title, text = item.title,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 2, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = when (item.type) { text = when (item.type) {
MediaKind.MOVIE -> "Movie" MediaKind.MOVIE -> "Movie"
MediaKind.SERIES -> "Series" MediaKind.SERIES -> "Series"
else -> "Title" else -> "Title"
}, },
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.bodySmall,
color = scheme.onSurfaceVariant color = scheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
) )
} }
} }
} }
}

View File

@@ -0,0 +1,12 @@
package hu.bbara.purefin.data
import hu.bbara.purefin.feature.search.SearchResult
import hu.bbara.purefin.model.Genre
import kotlinx.coroutines.flow.StateFlow
interface SearchManager {
val searchResult : StateFlow<List<SearchResult>>
val genres: StateFlow<Set<Genre>>
fun setGenres(genres : Set<String>)
fun setSearchTerm(searchTerm: String)
}

View File

@@ -1,65 +1,31 @@
package hu.bbara.purefin.feature.search package hu.bbara.purefin.feature.search
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.data.LocalMediaRepository import hu.bbara.purefin.data.SearchManager
import hu.bbara.purefin.data.UserSessionRepository
import hu.bbara.purefin.image.ArtworkKind
import hu.bbara.purefin.image.ImageUrlBuilder
import hu.bbara.purefin.model.MediaKind import hu.bbara.purefin.model.MediaKind
import hu.bbara.purefin.navigation.MovieDto import hu.bbara.purefin.navigation.MovieDto
import hu.bbara.purefin.navigation.NavigationManager import hu.bbara.purefin.navigation.NavigationManager
import hu.bbara.purefin.navigation.Route import hu.bbara.purefin.navigation.Route
import hu.bbara.purefin.navigation.SeriesDto import hu.bbara.purefin.navigation.SeriesDto
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@OptIn(FlowPreview::class)
class SearchViewModel @Inject constructor( class SearchViewModel @Inject constructor(
private val mediaCatalogReader: LocalMediaRepository, private val searchManager: SearchManager,
private val userSessionRepository: UserSessionRepository,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
) : ViewModel() { ) : ViewModel() {
val genres = mediaCatalogReader.genres val genres = searchManager.genres
val searchResult = searchManager.searchResult
private val _searchResult = MutableStateFlow<List<SearchResult>>(emptyList())
val searchResult = _searchResult.asStateFlow()
private val query = MutableStateFlow("")
init {
combine(
query.debounce(300).distinctUntilChanged(),
mediaCatalogReader.movies,
mediaCatalogReader.series
) { currentQuery, movies, series ->
val filteredMovies = movies.filter {
it.value.title.contains(currentQuery, ignoreCase = true)
}
val filteredSeries = series.filter {
it.value.name.contains(currentQuery, ignoreCase = true)
}
_searchResult.value = filteredMovies.values.map {
SearchResult.create(it, createImageUrl(it.id))
} + filteredSeries.values.map {
SearchResult.create(it, createImageUrl(it.id))
}
}.launchIn(viewModelScope)
}
fun search(query: String) { fun search(query: String) {
this.query.value = query searchManager.setSearchTerm(query)
}
fun setSelectedGenre(genreName: String?) {
searchManager.setGenres(genreName?.let { setOf(it) } ?: emptySet())
} }
fun onSearchResultSelected(searchResult: SearchResult) { fun onSearchResultSelected(searchResult: SearchResult) {
@@ -90,8 +56,4 @@ class SearchViewModel @Inject constructor(
) )
} }
private suspend fun createImageUrl(id: UUID) : String {
return ImageUrlBuilder.toImageUrl(userSessionRepository.serverUrl.first(), id,
ArtworkKind.PRIMARY)
}
} }

View File

@@ -0,0 +1,96 @@
package hu.bbara.purefin.data.jellyfin
import hu.bbara.purefin.data.SearchManager
import hu.bbara.purefin.data.UserSessionRepository
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
import hu.bbara.purefin.feature.search.SearchResult
import hu.bbara.purefin.image.ArtworkKind
import hu.bbara.purefin.image.ImageUrlBuilder
import hu.bbara.purefin.model.Genre
import hu.bbara.purefin.model.MediaKind
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SearchManagerImpl @Inject constructor(
private val jellyfinApiClient: JellyfinApiClient,
private val userSessionRepository: UserSessionRepository
) : SearchManager {
private suspend fun getServerUrl(): String {
return userSessionRepository.serverUrl.first()
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val searchTerm = MutableStateFlow("")
private val selectedGenres = MutableStateFlow<Set<String>>(emptySet())
private val _genres = MutableStateFlow<Set<Genre>>(emptySet())
override val searchResult: StateFlow<List<SearchResult>> =
combine(searchTerm, selectedGenres) { searchTerm, genres ->
search(searchTerm, genres)
}.stateIn(scope, SharingStarted.Eagerly, emptyList())
override val genres: StateFlow<Set<Genre>> = _genres.asStateFlow()
init {
scope.launch {
_genres.value = jellyfinApiClient.getGenres()
.mapNotNull { it.name }
.map { Genre(name = it) }
.toSet()
}
}
override fun setGenres(genres: Set<String>) {
selectedGenres.value = genres
}
override fun setSearchTerm(searchTerm: String) {
this.searchTerm.value = searchTerm
}
private suspend fun search(searchTerm: String, genres: Set<String>): List<SearchResult> {
if (searchTerm.isNotEmpty()) {
val searchBySearchTerm = jellyfinApiClient.searchBySearchTerm(searchTerm)
return searchBySearchTerm.map { it.toSearchResult() }
}
if (genres.isNotEmpty()) {
val searchByGenre = jellyfinApiClient.searchByGenre(genres)
return searchByGenre.map { it.toSearchResult() }
}
return emptyList()
}
private suspend fun BaseItemDto.toSearchResult(): SearchResult {
return SearchResult(
id = id,
title = name!!,
posterUrl = ImageUrlBuilder.toImageUrl(
url = getServerUrl(),
itemId = id,
artworkKind = ArtworkKind.PRIMARY
),
type = when (type) {
BaseItemKind.MOVIE -> MediaKind.MOVIE
BaseItemKind.SERIES -> MediaKind.SERIES
else -> throw UnsupportedOperationException("Unsupported media type: $type")
}
)
}
}

View File

@@ -116,13 +116,14 @@ class JellyfinApiClient @Inject constructor(
userId = getUserId(), userId = getUserId(),
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
searchTerm = searchTerm, searchTerm = searchTerm,
recursive = true
) )
Log.d("searchBySearchTerm", response.content.toString()) Log.d("searchBySearchTerm", response.content.toString())
response.content.items response.content.items
} }
} }
suspend fun searchByGenre(genres: List<String>): List<BaseItemDto> = withContext(Dispatchers.IO) { suspend fun searchByGenre(genres: Set<String>): List<BaseItemDto> = withContext(Dispatchers.IO) {
logApiFailure("searchMovie") { logApiFailure("searchMovie") {
if (!ensureConfigured()) { if (!ensureConfigured()) {
return@logApiFailure emptyList() return@logApiFailure emptyList()
@@ -130,7 +131,8 @@ class JellyfinApiClient @Inject constructor(
val response = api.itemsApi.getItems( val response = api.itemsApi.getItems(
userId = getUserId(), userId = getUserId(),
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
genres = genres genres = genres,
recursive = true
) )
Log.d("searchByGenre", response.content.toString()) Log.d("searchByGenre", response.content.toString())
response.content.items response.content.items

View File

@@ -8,9 +8,11 @@ import hu.bbara.purefin.Offline
import hu.bbara.purefin.Online import hu.bbara.purefin.Online
import hu.bbara.purefin.data.HomeRepository import hu.bbara.purefin.data.HomeRepository
import hu.bbara.purefin.data.LocalMediaRepository import hu.bbara.purefin.data.LocalMediaRepository
import hu.bbara.purefin.data.SearchManager
import hu.bbara.purefin.data.catalog.InMemoryAppContentRepository import hu.bbara.purefin.data.catalog.InMemoryAppContentRepository
import hu.bbara.purefin.data.catalog.InMemoryLocalMediaRepository import hu.bbara.purefin.data.catalog.InMemoryLocalMediaRepository
import hu.bbara.purefin.data.catalog.OfflineLocalMediaRepository import hu.bbara.purefin.data.catalog.OfflineLocalMediaRepository
import hu.bbara.purefin.data.jellyfin.SearchManagerImpl
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
@@ -26,4 +28,7 @@ abstract class MediaRepositoryModule {
@Binds @Binds
abstract fun bindHomeRepository(impl: InMemoryAppContentRepository): HomeRepository abstract fun bindHomeRepository(impl: InMemoryAppContentRepository): HomeRepository
@Binds
abstract fun bindSearchManager(impl: SearchManagerImpl): SearchManager
} }