`cargo clippy --all-targets` went from 51 warnings (23 in the lib) to zero. Most were mechanical — needless borrows, `assert_eq!` against a bool literal, `vec!` where an array does, `or_insert_with(Vec::new)`, a loop index used only to index — and were applied with `clippy --fix`, then reviewed line by line. That review caught one auto-fix that was *not* semantically neutral: dropping the redundant `use hostname;` left its `#[cfg(target_os = "linux")]` orphaned directly above `SERVICE_NAME`, which would have silently cfg'd the constant out of every non-Linux build. Removed the stray attribute with the import. Where a lint asked for a risky change rather than a better one, it is suppressed with a comment saying why: - `too_many_arguments` on five `#[tauri::command]` handlers and `ThumbnailCache::save_thumbnail` — most of the arity is `State<'_, _>` injection, and a parameter struct would change the IPC contract and the generated TypeScript for no readability gain. - `large_enum_variant` on `PlayerStatusEvent` and `AutoplayDecision` — both are serde + specta wire types emitted a handful of times a second, never bulk allocated; boxing would have to stay invisible to the generated bindings while every match arm gained a deref. - `await_holding_lock` on the `hybrid`/`offline` test modules — the guard is a test-only serialisation lock for the process-global `INCLUDE_CATALOG_BROWSE` flag, and the await it spans *is* the critical section. Each `#[tokio::test]` gets its own single-threaded runtime, so this is not the production deadlock class the lint targets; restructuring would reintroduce the flag race. Real fixes elsewhere: `JellyfinItem::to_media_item` takes `self` by value, so it is now `into_media_item`; the five-tuple episode row in the download commands has a named `EpisodeRow` alias; the mpv `PropertyChange` arm matches `name: "pause"` instead of guarding on it. Also converted the last 27 raw `.lock().unwrap()` call sites to `lock_safe()`, completing the `MutexSafe`/`RwLockSafe` convention. All of them turned out to be in test modules — production code was already clean — so this is consistency rather than a fix. The two raw locks in `utils/lock.rs` stay raw on purpose: those tests deliberately poison a mutex to prove the helpers recover from it. Pure refactoring: all 698 tests still pass.
315 lines
11 KiB
Rust
315 lines
11 KiB
Rust
//! 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 {
|
|
MediaItem {
|
|
id: format!("id-{}-{:?}", name, kind),
|
|
name: name.to_string(),
|
|
kind,
|
|
..MediaItem::default()
|
|
}
|
|
}
|
|
|
|
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
|
|
);
|
|
}
|
|
}
|