jellytau_lib/domain/search_rank.rs
1//! Relevance ranking for search results.
2//!
3//! Both search paths (the SQLite FTS cache and the Jellyfin server) return items
4//! in an order that ignores *where* in the name the query matched: a server
5//! substring hit like "Sparks of Love" can outrank "Parks and Recreation" for
6//! the query "parks". Neither backend is going to change, so the app imposes its
7//! own ordering on the union.
8//!
9//! Ranking is domain logic, not presentation: it encodes what a "better match"
10//! means and which media kinds outrank which. The frontend only renders the
11//! order it is given.
12//!
13//! Two rules, in priority order:
14//!
15//! 1. **Match position** — a prefix match beats a word-start match, which beats
16//! a mid-word substring match. This is what makes "parks" find
17//! "Parks and Recreation" before "Sparks of Love".
18//! 2. **Kind** — containers before their contents at equal match quality, so a
19//! series outranks its own episodes.
20//!
21//! Ties fall back to the input order, so a backend's own relevance signal (FTS
22//! `rank`) still breaks ties it was never overruled on.
23
24use crate::domain::MediaKind;
25use crate::repository::types::MediaItem;
26
27/// How well a query matched an item's name — better matches sort first.
28///
29/// Ordered by discriminant: `Prefix` is the strongest. Derived `Ord` gives the
30/// comparison for free, so adding a tier in the right position is all it takes.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
32pub enum MatchQuality {
33 /// The name starts with the query — "parks" in "Parks and Recreation".
34 Prefix,
35 /// Some later *word* starts with the query — "recreation" in "Parks and
36 /// Recreation". Still a deliberate hit: users type whole words.
37 WordStart,
38 /// The query appears mid-word — "parks" in "Sparks of Love". Weakest hit
39 /// that still counts as a match.
40 Substring,
41 /// No match on the name at all. The backend returned it for some other
42 /// reason (overview, artist, album), so it is kept but sorted last.
43 None,
44}
45
46/// Rank of a media kind when match quality ties — lower sorts first.
47///
48/// Containers outrank the items they contain: searching a show's name should
49/// surface the show, not an arbitrary episode of it. Within a tier the order is
50/// arbitrary but stable, and equal ranks fall through to input order.
51fn kind_rank(kind: MediaKind) -> u8 {
52 match kind {
53 // Top-level containers a user is most likely to be looking for.
54 MediaKind::Series | MediaKind::Movie | MediaKind::Album | MediaKind::Artist => 0,
55 // Sub-containers and standalone collections.
56 MediaKind::Season
57 | MediaKind::Playlist
58 | MediaKind::Channel
59 | MediaKind::ChannelFolder
60 | MediaKind::Folder => 1,
61 // Leaves — an episode/track is a match *inside* something bigger.
62 MediaKind::Episode | MediaKind::Track | MediaKind::LiveChannel | MediaKind::ChannelItem => {
63 2
64 }
65 // Peripheral matches.
66 MediaKind::Person | MediaKind::Other => 3,
67 }
68}
69
70/// Classify how `query` matches `name`, case-insensitively.
71///
72/// Both sides are trimmed and lowercased; an empty query matches everything
73/// equally (`Prefix`), which leaves the input order untouched.
74pub fn match_quality(name: &str, query: &str) -> MatchQuality {
75 let query = query.trim().to_lowercase();
76 if query.is_empty() {
77 return MatchQuality::Prefix;
78 }
79 let name = name.trim().to_lowercase();
80
81 let Some(index) = name.find(&query) else {
82 return MatchQuality::None;
83 };
84
85 if index == 0 {
86 return MatchQuality::Prefix;
87 }
88
89 // A word start is any match preceded by a non-alphanumeric character, so
90 // "the-office" and "The Office" behave the same. Indexing back one char is
91 // safe on the byte index `find` returned only via `char_indices`, since a
92 // multi-byte char would panic on a raw slice.
93 let preceded_by_boundary = name[..index]
94 .chars()
95 .next_back()
96 .is_some_and(|c| !c.is_alphanumeric());
97
98 if preceded_by_boundary {
99 MatchQuality::WordStart
100 } else {
101 MatchQuality::Substring
102 }
103}
104
105/// Sort search results by relevance to `query`, in place.
106///
107/// Stable, so items the rules rank equally keep the order the backend supplied
108/// (FTS `rank` for cache hits, Jellyfin's own ordering for server hits).
109///
110/// TRACES: UR-060 | DR-090
111pub fn rank_search_results(items: &mut [MediaItem], query: &str) {
112 // An empty query carries no relevance signal, so there is nothing to rank
113 // by — reordering on kind alone would shuffle the backend's own ordering
114 // for no reason.
115 if query.trim().is_empty() {
116 return;
117 }
118 items.sort_by_key(|item| (match_quality(&item.name, query), kind_rank(item.kind)));
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124
125 fn item(name: &str, kind: MediaKind) -> MediaItem {
126 MediaItem {
127 id: format!("id-{}-{:?}", name, kind),
128 name: name.to_string(),
129 kind,
130 ..MediaItem::default()
131 }
132 }
133
134 fn names(items: &[MediaItem]) -> Vec<&str> {
135 items.iter().map(|i| i.name.as_str()).collect()
136 }
137
138 /// UT-085: a prefix match outranks a mid-word substring match.
139 #[test]
140 fn prefix_match_beats_midword_substring() {
141 assert_eq!(
142 match_quality("Parks and Recreation", "parks"),
143 MatchQuality::Prefix
144 );
145 assert_eq!(
146 match_quality("Sparks of Love", "parks"),
147 MatchQuality::Substring
148 );
149 assert!(MatchQuality::Prefix < MatchQuality::Substring);
150 }
151
152 /// UT-085: the reported bug — "parks" must find the show, not "Sparks".
153 #[test]
154 fn ranks_prefix_match_before_substring_match() {
155 let mut items = vec![
156 item("Sparks of Love", MediaKind::Series),
157 item("Parks and Recreation", MediaKind::Series),
158 ];
159
160 rank_search_results(&mut items, "parks");
161
162 assert_eq!(
163 names(&items),
164 vec!["Parks and Recreation", "Sparks of Love"]
165 );
166 }
167
168 /// A match at a later word start beats a mid-word one but loses to a prefix.
169 #[test]
170 fn word_start_ranks_between_prefix_and_substring() {
171 assert_eq!(
172 match_quality("The Office", "office"),
173 MatchQuality::WordStart
174 );
175 assert_eq!(match_quality("Bofficer", "office"), MatchQuality::Substring);
176
177 let mut items = vec![
178 item("Bofficer", MediaKind::Series),
179 item("The Office", MediaKind::Series),
180 item("Office Space", MediaKind::Movie),
181 ];
182
183 rank_search_results(&mut items, "office");
184
185 assert_eq!(
186 names(&items),
187 vec!["Office Space", "The Office", "Bofficer"]
188 );
189 }
190
191 /// UT-086: at equal match quality a series outranks an episode.
192 #[test]
193 fn series_ranks_before_episode_at_equal_match_quality() {
194 let mut items = vec![
195 item("Parks and Recreation S01E01", MediaKind::Episode),
196 item("Parks and Recreation", MediaKind::Series),
197 ];
198
199 rank_search_results(&mut items, "parks");
200
201 assert_eq!(
202 names(&items),
203 vec!["Parks and Recreation", "Parks and Recreation S01E01"]
204 );
205 }
206
207 /// Albums outrank their tracks for the same reason series outrank episodes.
208 #[test]
209 fn album_ranks_before_track_at_equal_match_quality() {
210 let mut items = vec![
211 item("Rumours", MediaKind::Track),
212 item("Rumours", MediaKind::Album),
213 ];
214
215 rank_search_results(&mut items, "rumours");
216
217 assert_eq!(items[0].kind, MediaKind::Album);
218 }
219
220 /// Match quality dominates kind: a better-matching episode beats a
221 /// worse-matching series, so kind never drags an irrelevant show to the top.
222 #[test]
223 fn match_quality_outranks_kind() {
224 let mut items = vec![
225 item("Sparks of Love", MediaKind::Series),
226 item("Parks Cleanup", MediaKind::Episode),
227 ];
228
229 rank_search_results(&mut items, "parks");
230
231 assert_eq!(names(&items), vec!["Parks Cleanup", "Sparks of Love"]);
232 }
233
234 /// Items the backend returned for a non-name reason (overview, artist) are
235 /// kept, but sort below everything that actually matched the name.
236 #[test]
237 fn non_matching_names_sort_last_without_being_dropped() {
238 let mut items = vec![
239 item("Unrelated Documentary", MediaKind::Movie),
240 item("Parks and Recreation", MediaKind::Series),
241 ];
242
243 rank_search_results(&mut items, "parks");
244
245 assert_eq!(
246 names(&items),
247 vec!["Parks and Recreation", "Unrelated Documentary"]
248 );
249 }
250
251 /// Ranking is stable: equally-ranked items keep the backend's order, so the
252 /// FTS/server relevance signal still breaks ties.
253 #[test]
254 fn equal_rank_preserves_input_order() {
255 let mut items = vec![
256 item("Parks A", MediaKind::Series),
257 item("Parks B", MediaKind::Series),
258 item("Parks C", MediaKind::Series),
259 ];
260
261 rank_search_results(&mut items, "parks");
262
263 assert_eq!(names(&items), vec!["Parks A", "Parks B", "Parks C"]);
264 }
265
266 /// Case and surrounding whitespace never change the tier.
267 #[test]
268 fn matching_is_case_and_whitespace_insensitive() {
269 assert_eq!(
270 match_quality("PARKS AND RECREATION", " parks "),
271 MatchQuality::Prefix
272 );
273 assert_eq!(
274 match_quality("Parks and Recreation", "PARKS"),
275 MatchQuality::Prefix
276 );
277 }
278
279 /// An empty query leaves the order alone rather than reshuffling on kind.
280 #[test]
281 fn empty_query_preserves_input_order() {
282 let mut items = vec![
283 item("Zebra", MediaKind::Episode),
284 item("Apple", MediaKind::Series),
285 ];
286
287 rank_search_results(&mut items, "");
288
289 assert_eq!(names(&items), vec!["Zebra", "Apple"]);
290 }
291
292 /// A multi-byte name must not panic when the match is mid-string — the
293 /// boundary check walks chars rather than slicing raw bytes.
294 #[test]
295 fn handles_multibyte_names_without_panicking() {
296 assert_eq!(
297 match_quality("Pokémon Journeys", "journeys"),
298 MatchQuality::WordStart
299 );
300 assert_eq!(
301 match_quality("Café Parks", "parks"),
302 MatchQuality::WordStart
303 );
304 }
305
306 /// Punctuation counts as a word boundary, so "office" hits "The-Office".
307 #[test]
308 fn punctuation_counts_as_a_word_boundary() {
309 assert_eq!(
310 match_quality("The-Office", "office"),
311 MatchQuality::WordStart
312 );
313 assert_eq!(
314 match_quality("Show: Parks", "parks"),
315 MatchQuality::WordStart
316 );
317 }
318}