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 | MediaKind::Playlist | MediaKind::Channel | MediaKind::Folder => 1,
57 // Leaves — an episode/track is a match *inside* something bigger.
58 MediaKind::Episode | MediaKind::Track | MediaKind::LiveChannel | MediaKind::ChannelItem => {
59 2
60 }
61 // Peripheral matches.
62 MediaKind::Person | MediaKind::Other => 3,
63 }
64}
65
66/// Classify how `query` matches `name`, case-insensitively.
67///
68/// Both sides are trimmed and lowercased; an empty query matches everything
69/// equally (`Prefix`), which leaves the input order untouched.
70pub fn match_quality(name: &str, query: &str) -> MatchQuality {
71 let query = query.trim().to_lowercase();
72 if query.is_empty() {
73 return MatchQuality::Prefix;
74 }
75 let name = name.trim().to_lowercase();
76
77 let Some(index) = name.find(&query) else {
78 return MatchQuality::None;
79 };
80
81 if index == 0 {
82 return MatchQuality::Prefix;
83 }
84
85 // A word start is any match preceded by a non-alphanumeric character, so
86 // "the-office" and "The Office" behave the same. Indexing back one char is
87 // safe on the byte index `find` returned only via `char_indices`, since a
88 // multi-byte char would panic on a raw slice.
89 let preceded_by_boundary = name[..index]
90 .chars()
91 .next_back()
92 .is_some_and(|c| !c.is_alphanumeric());
93
94 if preceded_by_boundary {
95 MatchQuality::WordStart
96 } else {
97 MatchQuality::Substring
98 }
99}
100
101/// Sort search results by relevance to `query`, in place.
102///
103/// Stable, so items the rules rank equally keep the order the backend supplied
104/// (FTS `rank` for cache hits, Jellyfin's own ordering for server hits).
105///
106/// TRACES: UR-060 | DR-090
107pub fn rank_search_results(items: &mut [MediaItem], query: &str) {
108 // An empty query carries no relevance signal, so there is nothing to rank
109 // by — reordering on kind alone would shuffle the backend's own ordering
110 // for no reason.
111 if query.trim().is_empty() {
112 return;
113 }
114 items.sort_by_key(|item| (match_quality(&item.name, query), kind_rank(item.kind)));
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120
121 fn item(name: &str, kind: MediaKind) -> MediaItem {
122 MediaItem {
123 id: format!("id-{}-{:?}", name, kind),
124 name: name.to_string(),
125 kind,
126 ..MediaItem::default()
127 }
128 }
129
130 fn names(items: &[MediaItem]) -> Vec<&str> {
131 items.iter().map(|i| i.name.as_str()).collect()
132 }
133
134 /// UT-085: a prefix match outranks a mid-word substring match.
135 #[test]
136 fn prefix_match_beats_midword_substring() {
137 assert_eq!(
138 match_quality("Parks and Recreation", "parks"),
139 MatchQuality::Prefix
140 );
141 assert_eq!(
142 match_quality("Sparks of Love", "parks"),
143 MatchQuality::Substring
144 );
145 assert!(MatchQuality::Prefix < MatchQuality::Substring);
146 }
147
148 /// UT-085: the reported bug — "parks" must find the show, not "Sparks".
149 #[test]
150 fn ranks_prefix_match_before_substring_match() {
151 let mut items = vec![
152 item("Sparks of Love", MediaKind::Series),
153 item("Parks and Recreation", MediaKind::Series),
154 ];
155
156 rank_search_results(&mut items, "parks");
157
158 assert_eq!(
159 names(&items),
160 vec!["Parks and Recreation", "Sparks of Love"]
161 );
162 }
163
164 /// A match at a later word start beats a mid-word one but loses to a prefix.
165 #[test]
166 fn word_start_ranks_between_prefix_and_substring() {
167 assert_eq!(
168 match_quality("The Office", "office"),
169 MatchQuality::WordStart
170 );
171 assert_eq!(match_quality("Bofficer", "office"), MatchQuality::Substring);
172
173 let mut items = vec![
174 item("Bofficer", MediaKind::Series),
175 item("The Office", MediaKind::Series),
176 item("Office Space", MediaKind::Movie),
177 ];
178
179 rank_search_results(&mut items, "office");
180
181 assert_eq!(
182 names(&items),
183 vec!["Office Space", "The Office", "Bofficer"]
184 );
185 }
186
187 /// UT-086: at equal match quality a series outranks an episode.
188 #[test]
189 fn series_ranks_before_episode_at_equal_match_quality() {
190 let mut items = vec![
191 item("Parks and Recreation S01E01", MediaKind::Episode),
192 item("Parks and Recreation", MediaKind::Series),
193 ];
194
195 rank_search_results(&mut items, "parks");
196
197 assert_eq!(
198 names(&items),
199 vec!["Parks and Recreation", "Parks and Recreation S01E01"]
200 );
201 }
202
203 /// Albums outrank their tracks for the same reason series outrank episodes.
204 #[test]
205 fn album_ranks_before_track_at_equal_match_quality() {
206 let mut items = vec![
207 item("Rumours", MediaKind::Track),
208 item("Rumours", MediaKind::Album),
209 ];
210
211 rank_search_results(&mut items, "rumours");
212
213 assert_eq!(items[0].kind, MediaKind::Album);
214 }
215
216 /// Match quality dominates kind: a better-matching episode beats a
217 /// worse-matching series, so kind never drags an irrelevant show to the top.
218 #[test]
219 fn match_quality_outranks_kind() {
220 let mut items = vec![
221 item("Sparks of Love", MediaKind::Series),
222 item("Parks Cleanup", MediaKind::Episode),
223 ];
224
225 rank_search_results(&mut items, "parks");
226
227 assert_eq!(names(&items), vec!["Parks Cleanup", "Sparks of Love"]);
228 }
229
230 /// Items the backend returned for a non-name reason (overview, artist) are
231 /// kept, but sort below everything that actually matched the name.
232 #[test]
233 fn non_matching_names_sort_last_without_being_dropped() {
234 let mut items = vec![
235 item("Unrelated Documentary", MediaKind::Movie),
236 item("Parks and Recreation", MediaKind::Series),
237 ];
238
239 rank_search_results(&mut items, "parks");
240
241 assert_eq!(
242 names(&items),
243 vec!["Parks and Recreation", "Unrelated Documentary"]
244 );
245 }
246
247 /// Ranking is stable: equally-ranked items keep the backend's order, so the
248 /// FTS/server relevance signal still breaks ties.
249 #[test]
250 fn equal_rank_preserves_input_order() {
251 let mut items = vec![
252 item("Parks A", MediaKind::Series),
253 item("Parks B", MediaKind::Series),
254 item("Parks C", MediaKind::Series),
255 ];
256
257 rank_search_results(&mut items, "parks");
258
259 assert_eq!(names(&items), vec!["Parks A", "Parks B", "Parks C"]);
260 }
261
262 /// Case and surrounding whitespace never change the tier.
263 #[test]
264 fn matching_is_case_and_whitespace_insensitive() {
265 assert_eq!(
266 match_quality("PARKS AND RECREATION", " parks "),
267 MatchQuality::Prefix
268 );
269 assert_eq!(
270 match_quality("Parks and Recreation", "PARKS"),
271 MatchQuality::Prefix
272 );
273 }
274
275 /// An empty query leaves the order alone rather than reshuffling on kind.
276 #[test]
277 fn empty_query_preserves_input_order() {
278 let mut items = vec![
279 item("Zebra", MediaKind::Episode),
280 item("Apple", MediaKind::Series),
281 ];
282
283 rank_search_results(&mut items, "");
284
285 assert_eq!(names(&items), vec!["Zebra", "Apple"]);
286 }
287
288 /// A multi-byte name must not panic when the match is mid-string — the
289 /// boundary check walks chars rather than slicing raw bytes.
290 #[test]
291 fn handles_multibyte_names_without_panicking() {
292 assert_eq!(
293 match_quality("Pokémon Journeys", "journeys"),
294 MatchQuality::WordStart
295 );
296 assert_eq!(
297 match_quality("Café Parks", "parks"),
298 MatchQuality::WordStart
299 );
300 }
301
302 /// Punctuation counts as a word boundary, so "office" hits "The-Office".
303 #[test]
304 fn punctuation_counts_as_a_word_boundary() {
305 assert_eq!(
306 match_quality("The-Office", "office"),
307 MatchQuality::WordStart
308 );
309 assert_eq!(
310 match_quality("Show: Parks", "parks"),
311 MatchQuality::WordStart
312 );
313 }
314}