Search page improvements (#709)

## Description

Improves how search results are displayed:

- Reorders search categories to Movies → TV Shows → Episodes →
Collections, surfacing the most commonly searched content types at the
top.
- Adds a new "Combined search results" preference in Advanced settings →
Search. When enabled, shows all results in a single list instead of
separate categories.
- When Seerr is active, adds Library/Discover tabs to switch between
local library results and Seerr discovery results.
- Hides empty result categories instead of showing "No results"
placeholders.

### Screenshots

<img width="1935" height="1100" alt="Screenshot_20260116_113656"
src="https://github.com/user-attachments/assets/1e29aa3a-3ba2-4f86-ab61-fed683490474"
/>

<img width="1934" height="1095" alt="image"
src="https://github.com/user-attachments/assets/8b93fa38-d4c9-4e72-a997-caa82b4949f0"
/>

<img width="1921" height="1094" alt="image"
src="https://github.com/user-attachments/assets/82aed1a5-810c-413f-a266-6d78490a1534"
/>

### AI/LLM usage
Code auditing and PR description drafting with Claude Code. Also used
Claude Code for the regex.
This commit is contained in:
Justin Caveda 2026-05-13 09:02:33 -05:00 committed by GitHub
parent f364ef9708
commit 98ffaf51ac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 798 additions and 274 deletions

View file

@ -66,7 +66,8 @@ class AppPreferencesSerializer
directPlayPgs = AppPreference.DirectPlayPgs.defaultValue
mediaExtensionsEnabled =
AppPreference.FfmpegPreference.defaultValue
assPlaybackMode = AppPreference.AssSubtitleMode.defaultValue
assPlaybackMode =
AppPreference.AssSubtitleMode.defaultValue
}.build()
mpvOptions =
@ -99,6 +100,13 @@ class AppPreferencesSerializer
backdropStyle = AppPreference.BackdropStylePref.defaultValue
showLogos = AppPreference.ShowLogos.defaultValue
searchPreferences =
SearchPreferences
.newBuilder()
.apply {
combinedSearchResults = false
}.build()
subtitlesPreferences =
SubtitlePreferences
.newBuilder()
@ -207,6 +215,11 @@ inline fun AppPreferences.updateInterfacePreferences(block: InterfacePreferences
interfacePreferences = interfacePreferences.toBuilder().apply(block).build()
}
inline fun AppPreferences.updateSearchPreferences(block: SearchPreferences.Builder.() -> Unit): AppPreferences =
updateInterfacePreferences {
searchPreferences = searchPreferences.toBuilder().apply(block).build()
}
inline fun AppPreferences.updateSubtitlePreferences(block: SubtitlePreferences.Builder.() -> Unit): AppPreferences =
updateInterfacePreferences {
subtitlesPreferences = subtitlesPreferences.toBuilder().apply(block).build()

View file

@ -338,7 +338,7 @@ fun <T : CardGridItem> CardGrid(
val cardWidth =
ceil((width - (spacingPx * (columns - 1))) / columns)
cardWidthPx = cardWidth.toInt()
Timber.v("cardWidthPx=%s", cardWidthPx)
// Timber.v("cardWidthPx=%s", cardWidthPx)
},
) {
items(pager.size) { index ->

View file

@ -0,0 +1,89 @@
package com.github.damontecres.wholphin.util
import com.github.damontecres.wholphin.data.model.BaseItem
import org.jellyfin.sdk.model.api.BaseItemKind
import java.util.Locale
object SearchRelevance {
private val REGEX_PATTERN = Regex("^/(.+)/(i)?$")
fun score(
item: BaseItem,
query: String,
): Int {
val name = item.name?.lowercase(Locale.getDefault()) ?: return Int.MAX_VALUE
val q = query.lowercase(Locale.getDefault())
REGEX_PATTERN.find(query)?.let { match ->
val pattern = match.groupValues[1]
val caseInsensitive = match.groupValues[2] == "i"
return regexScore(item.name ?: "", pattern, caseInsensitive) + typeBonus(item.type)
}
return when {
name == q -> 0
name.startsWith(q) -> 100
name.contains(" $q") -> 200
name.contains(q) -> 300
else -> fuzzyScore(name, q)
} + typeBonus(item.type)
}
private fun regexScore(
name: String,
pattern: String,
caseInsensitive: Boolean,
): Int =
try {
val options = if (caseInsensitive) setOf(RegexOption.IGNORE_CASE) else emptySet()
if (Regex(pattern, options).containsMatchIn(name)) 250 else 400
} catch (e: Exception) {
400
}
private fun fuzzyScore(
name: String,
query: String,
): Int {
val distance = levenshteinDistance(name, query)
val maxLen = maxOf(name.length, query.length)
val similarity = 1.0 - (distance.toDouble() / maxLen)
return when {
similarity >= 0.8 -> 350
similarity >= 0.6 -> 375
else -> 400
}
}
private fun levenshteinDistance(
s1: String,
s2: String,
): Int {
val m = s1.length
val n = s2.length
val dp = Array(m + 1) { IntArray(n + 1) }
for (i in 0..m) dp[i][0] = i
for (j in 0..n) dp[0][j] = j
for (i in 1..m) {
for (j in 1..n) {
dp[i][j] =
if (s1[i - 1] == s2[j - 1]) {
dp[i - 1][j - 1]
} else {
1 + minOf(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
}
}
}
return dp[m][n]
}
private fun typeBonus(type: BaseItemKind): Int =
when (type) {
BaseItemKind.SERIES -> 0
BaseItemKind.MOVIE -> 1
BaseItemKind.BOX_SET -> 2
BaseItemKind.EPISODE -> 10
else -> 5
}
}

View file

@ -165,6 +165,10 @@ message ScreensaverPreferences{
bool show_clock = 7;
}
message SearchPreferences {
bool combined_search_results = 1;
}
message InterfacePreferences {
ThemeSongVolume play_theme_songs = 1;
bool remember_selected_tab = 2;
@ -179,6 +183,7 @@ message InterfacePreferences {
ScreensaverPreferences screensaver_preference = 11;
bool enable_media_management = 12;
bool show_logos = 13;
SearchPreferences search_preferences = 14;
}
message AdvancedPreferences {

View file

@ -445,6 +445,10 @@
<string name="color_code_programs">Color-code programs</string>
<string name="subtitle_delay">Subtitle delay</string>
<string name="backdrop_display">Backdrop style</string>
<string name="combined_search_results">Combined search results</string>
<string name="combined_search_results_on">Show all results in a single list</string>
<string name="combined_search_results_off">Show results by category</string>
<string name="results">Results</string>
<string name="resolution_switching">Resolution switching</string>
<string name="local">Local</string>
<string name="play_trailer">Play trailer</string>