//! Relevance ranking for search results. //! //! Both search paths (the SQLite FTS cache and the Jellyfin server) return items //! in an order that ignores *where* in the name the query matched: a server //! substring hit like "Sparks of Love" can outrank "Parks and Recreation" for //! the query "parks". Neither backend is going to change, so the app imposes its //! own ordering on the union. //! //! Ranking is domain logic, not presentation: it encodes what a "better match" //! means and which media kinds outrank which. The frontend only renders the //! order it is given. //! //! Two rules, in priority order: //! //! 1. **Match position** — a prefix match beats a word-start match, which beats //! a mid-word substring match. This is what makes "parks" find //! "Parks and Recreation" before "Sparks of Love". //! 2. **Kind** — containers before their contents at equal match quality, so a //! series outranks its own episodes. //! //! Ties fall back to the input order, so a backend's own relevance signal (FTS //! `rank`) still breaks ties it was never overruled on. use crate::domain::MediaKind; use crate::repository::types::MediaItem; /// How well a query matched an item's name — better matches sort first. /// /// Ordered by discriminant: `Prefix` is the strongest. Derived `Ord` gives the /// comparison for free, so adding a tier in the right position is all it takes. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum MatchQuality { /// The name starts with the query — "parks" in "Parks and Recreation". Prefix, /// Some later *word* starts with the query — "recreation" in "Parks and /// Recreation". Still a deliberate hit: users type whole words. WordStart, /// The query appears mid-word — "parks" in "Sparks of Love". Weakest hit /// that still counts as a match. Substring, /// No match on the name at all. The backend returned it for some other /// reason (overview, artist, album), so it is kept but sorted last. None, } /// Rank of a media kind when match quality ties — lower sorts first. /// /// Containers outrank the items they contain: searching a show's name should /// surface the show, not an arbitrary episode of it. Within a tier the order is /// arbitrary but stable, and equal ranks fall through to input order. fn kind_rank(kind: MediaKind) -> u8 { match kind { // Top-level containers a user is most likely to be looking for. MediaKind::Series | MediaKind::Movie | MediaKind::Album | MediaKind::Artist => 0, // Sub-containers and standalone collections. MediaKind::Season | MediaKind::Playlist | MediaKind::Channel | MediaKind::Folder => 1, // Leaves — an episode/track is a match *inside* something bigger. MediaKind::Episode | MediaKind::Track | MediaKind::LiveChannel | MediaKind::ChannelItem => { 2 } // Peripheral matches. MediaKind::Person | MediaKind::Other => 3, } } /// Classify how `query` matches `name`, case-insensitively. /// /// Both sides are trimmed and lowercased; an empty query matches everything /// equally (`Prefix`), which leaves the input order untouched. pub fn match_quality(name: &str, query: &str) -> MatchQuality { let query = query.trim().to_lowercase(); if query.is_empty() { return MatchQuality::Prefix; } let name = name.trim().to_lowercase(); let Some(index) = name.find(&query) else { return MatchQuality::None; }; if index == 0 { return MatchQuality::Prefix; } // A word start is any match preceded by a non-alphanumeric character, so // "the-office" and "The Office" behave the same. Indexing back one char is // safe on the byte index `find` returned only via `char_indices`, since a // multi-byte char would panic on a raw slice. let preceded_by_boundary = name[..index] .chars() .next_back() .is_some_and(|c| !c.is_alphanumeric()); if preceded_by_boundary { MatchQuality::WordStart } else { MatchQuality::Substring } } /// Sort search results by relevance to `query`, in place. /// /// Stable, so items the rules rank equally keep the order the backend supplied /// (FTS `rank` for cache hits, Jellyfin's own ordering for server hits). /// /// TRACES: UR-060 | DR-090 pub fn rank_search_results(items: &mut [MediaItem], query: &str) { // An empty query carries no relevance signal, so there is nothing to rank // by — reordering on kind alone would shuffle the backend's own ordering // for no reason. if query.trim().is_empty() { return; } items.sort_by_key(|item| (match_quality(&item.name, query), kind_rank(item.kind))); } #[cfg(test)] mod tests { use super::*; fn item(name: &str, kind: MediaKind) -> MediaItem { let mut item = MediaItem::default(); item.id = format!("id-{}-{:?}", name, kind); item.name = name.to_string(); item.kind = kind; item } fn names(items: &[MediaItem]) -> Vec<&str> { items.iter().map(|i| i.name.as_str()).collect() } /// UT-085: a prefix match outranks a mid-word substring match. #[test] fn prefix_match_beats_midword_substring() { assert_eq!( match_quality("Parks and Recreation", "parks"), MatchQuality::Prefix ); assert_eq!( match_quality("Sparks of Love", "parks"), MatchQuality::Substring ); assert!(MatchQuality::Prefix < MatchQuality::Substring); } /// UT-085: the reported bug — "parks" must find the show, not "Sparks". #[test] fn ranks_prefix_match_before_substring_match() { let mut items = vec![ item("Sparks of Love", MediaKind::Series), item("Parks and Recreation", MediaKind::Series), ]; rank_search_results(&mut items, "parks"); assert_eq!( names(&items), vec!["Parks and Recreation", "Sparks of Love"] ); } /// A match at a later word start beats a mid-word one but loses to a prefix. #[test] fn word_start_ranks_between_prefix_and_substring() { assert_eq!( match_quality("The Office", "office"), MatchQuality::WordStart ); assert_eq!(match_quality("Bofficer", "office"), MatchQuality::Substring); let mut items = vec![ item("Bofficer", MediaKind::Series), item("The Office", MediaKind::Series), item("Office Space", MediaKind::Movie), ]; rank_search_results(&mut items, "office"); assert_eq!( names(&items), vec!["Office Space", "The Office", "Bofficer"] ); } /// UT-086: at equal match quality a series outranks an episode. #[test] fn series_ranks_before_episode_at_equal_match_quality() { let mut items = vec![ item("Parks and Recreation S01E01", MediaKind::Episode), item("Parks and Recreation", MediaKind::Series), ]; rank_search_results(&mut items, "parks"); assert_eq!( names(&items), vec!["Parks and Recreation", "Parks and Recreation S01E01"] ); } /// Albums outrank their tracks for the same reason series outrank episodes. #[test] fn album_ranks_before_track_at_equal_match_quality() { let mut items = vec![ item("Rumours", MediaKind::Track), item("Rumours", MediaKind::Album), ]; rank_search_results(&mut items, "rumours"); assert_eq!(items[0].kind, MediaKind::Album); } /// Match quality dominates kind: a better-matching episode beats a /// worse-matching series, so kind never drags an irrelevant show to the top. #[test] fn match_quality_outranks_kind() { let mut items = vec![ item("Sparks of Love", MediaKind::Series), item("Parks Cleanup", MediaKind::Episode), ]; rank_search_results(&mut items, "parks"); assert_eq!(names(&items), vec!["Parks Cleanup", "Sparks of Love"]); } /// Items the backend returned for a non-name reason (overview, artist) are /// kept, but sort below everything that actually matched the name. #[test] fn non_matching_names_sort_last_without_being_dropped() { let mut items = vec![ item("Unrelated Documentary", MediaKind::Movie), item("Parks and Recreation", MediaKind::Series), ]; rank_search_results(&mut items, "parks"); assert_eq!( names(&items), vec!["Parks and Recreation", "Unrelated Documentary"] ); } /// Ranking is stable: equally-ranked items keep the backend's order, so the /// FTS/server relevance signal still breaks ties. #[test] fn equal_rank_preserves_input_order() { let mut items = vec![ item("Parks A", MediaKind::Series), item("Parks B", MediaKind::Series), item("Parks C", MediaKind::Series), ]; rank_search_results(&mut items, "parks"); assert_eq!(names(&items), vec!["Parks A", "Parks B", "Parks C"]); } /// Case and surrounding whitespace never change the tier. #[test] fn matching_is_case_and_whitespace_insensitive() { assert_eq!( match_quality("PARKS AND RECREATION", " parks "), MatchQuality::Prefix ); assert_eq!( match_quality("Parks and Recreation", "PARKS"), MatchQuality::Prefix ); } /// An empty query leaves the order alone rather than reshuffling on kind. #[test] fn empty_query_preserves_input_order() { let mut items = vec![ item("Zebra", MediaKind::Episode), item("Apple", MediaKind::Series), ]; rank_search_results(&mut items, ""); assert_eq!(names(&items), vec!["Zebra", "Apple"]); } /// A multi-byte name must not panic when the match is mid-string — the /// boundary check walks chars rather than slicing raw bytes. #[test] fn handles_multibyte_names_without_panicking() { assert_eq!( match_quality("Pokémon Journeys", "journeys"), MatchQuality::WordStart ); assert_eq!( match_quality("Café Parks", "parks"), MatchQuality::WordStart ); } /// Punctuation counts as a word boundary, so "office" hits "The-Office". #[test] fn punctuation_counts_as_a_word_boundary() { assert_eq!( match_quality("The-Office", "office"), MatchQuality::WordStart ); assert_eq!( match_quality("Show: Parks", "parks"), MatchQuality::WordStart ); } }