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}