feat(search): rank results by match quality and split TV/People groups

Neither search backend orders by *where* the query matched, so a mid-word hit
could outrank a prefix one — typing "parks" surfaced "Sparks of Love" above
"Parks and Recreation".

Add `domain/search_rank.rs`, which sorts by match position (prefix →
word-start → mid-word substring → no name match), then by media kind so a
container outranks its own contents. The sort is stable, so each backend's own
relevance still breaks ties it was never overruled on. `repository_search`
applies it to both the instant cache result and the merged cache+server union,
so the list does not reshuffle when server results land. Ranking lives in Rust
because "a better match" is domain vocabulary, not presentation.

On the frontend, the combined `tvShows` result group splits into separate
Shows and Episodes groups so a show no longer competes with its own episodes
for a slot, and a People group is added so searching an actor's name reaches
their bio. A stored `tvShows` order expands in place, keeping the position an
upgrading user chose for it.
This commit is contained in:
2026-07-25 15:13:32 +02:00
parent 7650efcb7f
commit 5927299c0f
7 changed files with 563 additions and 77 deletions
+13 -2
View File
@@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, State};
use uuid::Uuid;
use crate::domain::rank_search_results;
use crate::jellyfin::HttpClient;
use crate::repository::{
types::*, HybridRepository, MediaRepository, OfflineRepository, OnlineRepository,
@@ -409,7 +410,7 @@ pub async fn repository_search(
// Phase 1: instant local results from the cache (downloaded content) so the
// UI can render immediately while the server is still being queried.
let cache_result = repo
let mut cache_result = repo
.search_cache_only(&query, options.clone())
.await
.unwrap_or_else(|e| {
@@ -420,6 +421,12 @@ pub async fn repository_search(
}
});
// Neither backend orders by *where* the query matched, so a mid-word hit
// ("Sparks" for "parks") can outrank a prefix hit ("Parks and Recreation").
// Both phases are ranked with the same rules so the list does not reshuffle
// when the server results land.
rank_search_results(&mut cache_result.items, &query);
// Phase 2: query the live server in the background, merge with the cache,
// and push the union to the frontend via a `search-event`. Tagged with
// `request_id` so the frontend can discard results from superseded queries.
@@ -428,7 +435,11 @@ pub async fn repository_search(
tauri::async_runtime::spawn(async move {
match repo_bg.search_server_only(&query, options).await {
Ok(server_result) => {
let merged = HybridRepository::merge_search_results(cache_for_merge, server_result);
let mut merged =
HybridRepository::merge_search_results(cache_for_merge, server_result);
// Rank the union, not each half: a server-only prefix match must
// be able to outrank a cached mid-word one.
rank_search_results(&mut merged.items, &query);
let event = SearchUpdateEvent {
request_id,
result: merged,