Skip to main content

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}