From 6433225014c29042896d047d2155f9e70a9f06be Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Sat, 2 May 2026 17:40:56 +0200 Subject: [PATCH] feat(search): add Jellyfin-backed search --- .../components/search/SearchFullScreen.kt | 46 ++++++--- .../components/search/SearchResultCard.kt | 83 +++++++++------- .../hu/bbara/purefin/data/SearchManager.kt | 12 +++ .../purefin/feature/search/SearchViewModel.kt | 56 ++--------- .../data/jellyfin/SearchManagerImpl.kt | 96 +++++++++++++++++++ .../data/jellyfin/client/JellyfinApiClient.kt | 6 +- .../data/module/MediaRepositoryModule.kt | 5 + 7 files changed, 208 insertions(+), 96 deletions(-) create mode 100644 core/src/main/java/hu/bbara/purefin/data/SearchManager.kt create mode 100644 data/src/main/java/hu/bbara/purefin/data/jellyfin/SearchManagerImpl.kt diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/home/components/search/SearchFullScreen.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/home/components/search/SearchFullScreen.kt index 17c2a98c..296a7d9e 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/home/components/search/SearchFullScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/home/components/search/SearchFullScreen.kt @@ -113,6 +113,7 @@ fun SearchFullScreen( onResultClick = viewModel::onSearchResultSelected, onGenreSelected = { genre -> selectedGenreName = genre.name.takeIf { it != selectedGenreName } + viewModel.setSelectedGenre(selectedGenreName) }, modifier = modifier.then(sharedBoundsModifier) ) @@ -158,24 +159,47 @@ private fun SearchFullScreenContent( selectedGenreName = selectedGenreName, onGenreSelected = onGenreSelected ) - } else { - SectionTitle(text = "Search Results") - Spacer(modifier = Modifier.height(16.dp)) - if (searchResults.isEmpty()) { - SearchMessage( - title = "No matches", - body = "Try a different title or browse your libraries." - ) - } else { - SearchResultsGrid( - results = searchResults, + 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 { + SectionTitle(text = "Search Results") + Spacer(modifier = Modifier.height(16.dp)) + SearchResults( + searchResults = searchResults, + onResultClick = onResultClick + ) } } } +@Composable +private fun SearchResults( + searchResults: List, + 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 private fun SearchHeader( modifier: Modifier = Modifier diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/home/components/search/SearchResultCard.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/home/components/search/SearchResultCard.kt index 277b296b..03dce3dc 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/home/components/search/SearchResultCard.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/home/components/search/SearchResultCard.kt @@ -1,14 +1,18 @@ package hu.bbara.purefin.ui.screen.home.components.search -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box 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.height import androidx.compose.foundation.layout.padding 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.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable 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.style.TextOverflow import androidx.compose.ui.unit.dp -import hu.bbara.purefin.model.MediaKind import hu.bbara.purefin.feature.search.SearchResult +import hu.bbara.purefin.model.MediaKind import hu.bbara.purefin.ui.common.image.PurefinAsyncImage @Composable @@ -29,43 +33,50 @@ internal fun SearchResultCard( ) { val scheme = MaterialTheme.colorScheme - Surface( - shape = RoundedCornerShape(22.dp), - color = scheme.surfaceContainer, + Card( + onClick = onClick, + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = scheme.surfaceContainer), modifier = modifier .fillMaxWidth() - .clip(RoundedCornerShape(22.dp)) - .clickable(onClick = onClick) ) { - Column( - verticalArrangement = Arrangement.spacedBy(10.dp), - modifier = Modifier.padding(12.dp) - ) { - PurefinAsyncImage( - model = item.posterUrl, - contentDescription = item.title, - contentScale = ContentScale.Crop, + Column(modifier = Modifier.fillMaxWidth()) { + Box( modifier = Modifier .fillMaxWidth() - .height(180.dp) - .clip(RoundedCornerShape(18.dp)) - ) - Text( - text = item.title, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.SemiBold, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - Text( - text = when (item.type) { - MediaKind.MOVIE -> "Movie" - MediaKind.SERIES -> "Series" - else -> "Title" - }, - style = MaterialTheme.typography.labelMedium, - color = scheme.onSurfaceVariant - ) + .aspectRatio(16f / 10f) + .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) + .background(scheme.surface) + ) { + PurefinAsyncImage( + model = item.posterUrl, + contentDescription = item.title, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } + Spacer(modifier = Modifier.height(10.dp)) + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = item.title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = when (item.type) { + MediaKind.MOVIE -> "Movie" + MediaKind.SERIES -> "Series" + else -> "Title" + }, + style = MaterialTheme.typography.bodySmall, + color = scheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } } } } diff --git a/core/src/main/java/hu/bbara/purefin/data/SearchManager.kt b/core/src/main/java/hu/bbara/purefin/data/SearchManager.kt new file mode 100644 index 00000000..b3b20af5 --- /dev/null +++ b/core/src/main/java/hu/bbara/purefin/data/SearchManager.kt @@ -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> + val genres: StateFlow> + fun setGenres(genres : Set) + fun setSearchTerm(searchTerm: String) +} diff --git a/core/src/main/java/hu/bbara/purefin/feature/search/SearchViewModel.kt b/core/src/main/java/hu/bbara/purefin/feature/search/SearchViewModel.kt index 70b3d060..27b0409d 100644 --- a/core/src/main/java/hu/bbara/purefin/feature/search/SearchViewModel.kt +++ b/core/src/main/java/hu/bbara/purefin/feature/search/SearchViewModel.kt @@ -1,65 +1,31 @@ package hu.bbara.purefin.feature.search import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import hu.bbara.purefin.data.LocalMediaRepository -import hu.bbara.purefin.data.UserSessionRepository -import hu.bbara.purefin.image.ArtworkKind -import hu.bbara.purefin.image.ImageUrlBuilder +import hu.bbara.purefin.data.SearchManager import hu.bbara.purefin.model.MediaKind import hu.bbara.purefin.navigation.MovieDto import hu.bbara.purefin.navigation.NavigationManager import hu.bbara.purefin.navigation.Route 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 javax.inject.Inject @HiltViewModel -@OptIn(FlowPreview::class) class SearchViewModel @Inject constructor( - private val mediaCatalogReader: LocalMediaRepository, - private val userSessionRepository: UserSessionRepository, + private val searchManager: SearchManager, private val navigationManager: NavigationManager, ) : ViewModel() { - val genres = mediaCatalogReader.genres - - private val _searchResult = MutableStateFlow>(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) - } + val genres = searchManager.genres + val searchResult = searchManager.searchResult 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) { @@ -90,8 +56,4 @@ class SearchViewModel @Inject constructor( ) } - private suspend fun createImageUrl(id: UUID) : String { - return ImageUrlBuilder.toImageUrl(userSessionRepository.serverUrl.first(), id, - ArtworkKind.PRIMARY) - } } diff --git a/data/src/main/java/hu/bbara/purefin/data/jellyfin/SearchManagerImpl.kt b/data/src/main/java/hu/bbara/purefin/data/jellyfin/SearchManagerImpl.kt new file mode 100644 index 00000000..5bebb3f1 --- /dev/null +++ b/data/src/main/java/hu/bbara/purefin/data/jellyfin/SearchManagerImpl.kt @@ -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>(emptySet()) + private val _genres = MutableStateFlow>(emptySet()) + + override val searchResult: StateFlow> = + combine(searchTerm, selectedGenres) { searchTerm, genres -> + search(searchTerm, genres) + }.stateIn(scope, SharingStarted.Eagerly, emptyList()) + + override val genres: StateFlow> = _genres.asStateFlow() + + init { + scope.launch { + _genres.value = jellyfinApiClient.getGenres() + .mapNotNull { it.name } + .map { Genre(name = it) } + .toSet() + } + } + + override fun setGenres(genres: Set) { + selectedGenres.value = genres + } + + override fun setSearchTerm(searchTerm: String) { + this.searchTerm.value = searchTerm + } + + private suspend fun search(searchTerm: String, genres: Set): List { + 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") + } + ) + } + + +} diff --git a/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt b/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt index e3f4edeb..1da88081 100644 --- a/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt +++ b/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt @@ -116,13 +116,14 @@ class JellyfinApiClient @Inject constructor( userId = getUserId(), includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), searchTerm = searchTerm, + recursive = true ) Log.d("searchBySearchTerm", response.content.toString()) response.content.items } } - suspend fun searchByGenre(genres: List): List = withContext(Dispatchers.IO) { + suspend fun searchByGenre(genres: Set): List = withContext(Dispatchers.IO) { logApiFailure("searchMovie") { if (!ensureConfigured()) { return@logApiFailure emptyList() @@ -130,7 +131,8 @@ class JellyfinApiClient @Inject constructor( val response = api.itemsApi.getItems( userId = getUserId(), includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), - genres = genres + genres = genres, + recursive = true ) Log.d("searchByGenre", response.content.toString()) response.content.items diff --git a/data/src/main/java/hu/bbara/purefin/data/module/MediaRepositoryModule.kt b/data/src/main/java/hu/bbara/purefin/data/module/MediaRepositoryModule.kt index f8b8c55f..2e6e193f 100644 --- a/data/src/main/java/hu/bbara/purefin/data/module/MediaRepositoryModule.kt +++ b/data/src/main/java/hu/bbara/purefin/data/module/MediaRepositoryModule.kt @@ -8,9 +8,11 @@ import hu.bbara.purefin.Offline import hu.bbara.purefin.Online import hu.bbara.purefin.data.HomeRepository 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.InMemoryLocalMediaRepository import hu.bbara.purefin.data.catalog.OfflineLocalMediaRepository +import hu.bbara.purefin.data.jellyfin.SearchManagerImpl @Module @InstallIn(SingletonComponent::class) @@ -26,4 +28,7 @@ abstract class MediaRepositoryModule { @Binds abstract fun bindHomeRepository(impl: InMemoryAppContentRepository): HomeRepository + + @Binds + abstract fun bindSearchManager(impl: SearchManagerImpl): SearchManager } \ No newline at end of file