From 5927299c0fedeaafc5c76219a1e14b6f098eb61f Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 25 Jul 2026 15:13:32 +0200 Subject: [PATCH] feat(search): rank results by match quality and split TV/People groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neither search backend orders by *where* the query matched, so a mid-word hit could outrank a prefix one — typing "parks" surfaced "Sparks of Love" above "Parks and Recreation". Add `domain/search_rank.rs`, which sorts by match position (prefix → word-start → mid-word substring → no name match), then by media kind so a container outranks its own contents. The sort is stable, so each backend's own relevance still breaks ties it was never overruled on. `repository_search` applies it to both the instant cache result and the merged cache+server union, so the list does not reshuffle when server results land. Ranking lives in Rust because "a better match" is domain vocabulary, not presentation. On the frontend, the combined `tvShows` result group splits into separate Shows and Episodes groups so a show no longer competes with its own episodes for a slot, and a People group is added so searching an actor's name reaches their bio. A stored `tvShows` order expands in place, keeping the position an upgrading user chose for it. --- src-tauri/src/commands/repository.rs | 15 +- src-tauri/src/domain/mod.rs | 2 + src-tauri/src/domain/search_rank.rs | 313 ++++++++++++++++++ .../components/search/SearchResults.svelte | 2 +- src/lib/stores/searchGroupOrder.test.ts | 69 ++-- src/lib/utils/searchScope.test.ts | 150 +++++++-- src/lib/utils/searchScope.ts | 89 ++++- 7 files changed, 563 insertions(+), 77 deletions(-) create mode 100644 src-tauri/src/domain/search_rank.rs diff --git a/src-tauri/src/commands/repository.rs b/src-tauri/src/commands/repository.rs index 6f2028ef..eee6c1e2 100644 --- a/src-tauri/src/commands/repository.rs +++ b/src-tauri/src/commands/repository.rs @@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Emitter, State}; use uuid::Uuid; +use crate::domain::rank_search_results; use crate::jellyfin::HttpClient; use crate::repository::{ types::*, HybridRepository, MediaRepository, OfflineRepository, OnlineRepository, @@ -409,7 +410,7 @@ pub async fn repository_search( // Phase 1: instant local results from the cache (downloaded content) so the // UI can render immediately while the server is still being queried. - let cache_result = repo + let mut cache_result = repo .search_cache_only(&query, options.clone()) .await .unwrap_or_else(|e| { @@ -420,6 +421,12 @@ pub async fn repository_search( } }); + // Neither backend orders by *where* the query matched, so a mid-word hit + // ("Sparks" for "parks") can outrank a prefix hit ("Parks and Recreation"). + // Both phases are ranked with the same rules so the list does not reshuffle + // when the server results land. + rank_search_results(&mut cache_result.items, &query); + // Phase 2: query the live server in the background, merge with the cache, // and push the union to the frontend via a `search-event`. Tagged with // `request_id` so the frontend can discard results from superseded queries. @@ -428,7 +435,11 @@ pub async fn repository_search( tauri::async_runtime::spawn(async move { match repo_bg.search_server_only(&query, options).await { Ok(server_result) => { - let merged = HybridRepository::merge_search_results(cache_for_merge, server_result); + let mut merged = + HybridRepository::merge_search_results(cache_for_merge, server_result); + // Rank the union, not each half: a server-only prefix match must + // be able to outrank a cached mid-word one. + rank_search_results(&mut merged.items, &query); let event = SearchUpdateEvent { request_id, result: merged, diff --git a/src-tauri/src/domain/mod.rs b/src-tauri/src/domain/mod.rs index 8cbeea61..979accf3 100644 --- a/src-tauri/src/domain/mod.rs +++ b/src-tauri/src/domain/mod.rs @@ -5,6 +5,8 @@ pub mod from_jellyfin; pub mod media; +pub mod search_rank; pub use from_jellyfin::{kind_from_jellyfin, stream_kind_from_jellyfin, ticks_to_ms}; pub use media::{MediaKind, StreamKind}; +pub use search_rank::rank_search_results; diff --git a/src-tauri/src/domain/search_rank.rs b/src-tauri/src/domain/search_rank.rs new file mode 100644 index 00000000..80f7fc7b --- /dev/null +++ b/src-tauri/src/domain/search_rank.rs @@ -0,0 +1,313 @@ +//! 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 { + let mut item = MediaItem::default(); + item.id = format!("id-{}-{:?}", name, kind); + item.name = name.to_string(); + item.kind = kind; + item + } + + 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 + ); + } +} diff --git a/src/lib/components/search/SearchResults.svelte b/src/lib/components/search/SearchResults.svelte index c88266e4..ec023688 100644 --- a/src/lib/components/search/SearchResults.svelte +++ b/src/lib/components/search/SearchResults.svelte @@ -53,7 +53,7 @@ onItemClick?.(item)} /> {/each} diff --git a/src/lib/stores/searchGroupOrder.test.ts b/src/lib/stores/searchGroupOrder.test.ts index 873f4c1f..9ad86dfe 100644 --- a/src/lib/stores/searchGroupOrder.test.ts +++ b/src/lib/stores/searchGroupOrder.test.ts @@ -42,15 +42,33 @@ describe("searchGroupOrder", () => { it("loads a stored order", async () => { localStorage.setItem( STORAGE_KEY, - JSON.stringify(["tvShows", "movies", "songs", "albums", "artists"]) + JSON.stringify(["episodes", "shows", "movies", "songs", "albums", "artists", "people"]) ); const { searchGroupOrder } = await import("./searchGroupOrder"); expect(get(searchGroupOrder)).toEqual([ - "tvShows", + "episodes", + "shows", "movies", "songs", "albums", "artists", + "people", + ]); + }); + + it("migrates a stored `tvShows` from before the group split", async () => { + // Upgrading must keep the user's placement of TV, not append the two new + // groups at the bottom. + localStorage.setItem(STORAGE_KEY, JSON.stringify(["tvShows", "movies"])); + const { searchGroupOrder } = await import("./searchGroupOrder"); + expect(get(searchGroupOrder)).toEqual([ + "shows", + "episodes", + "movies", + "songs", + "albums", + "artists", + "people", ]); }); @@ -59,10 +77,12 @@ describe("searchGroupOrder", () => { const { searchGroupOrder } = await import("./searchGroupOrder"); expect(get(searchGroupOrder)).toEqual([ "movies", + "shows", + "episodes", "songs", "albums", "artists", - "tvShows", + "people", ]); }); @@ -74,50 +94,45 @@ describe("searchGroupOrder", () => { it("persists a move so the order survives a restart", async () => { const { searchGroupOrder } = await import("./searchGroupOrder"); + // Default is shows, episodes, movies, songs, … — move movies up one. searchGroupOrder.move("movies", -1); - expect(get(searchGroupOrder)).toEqual([ + const expected = [ + "shows", + "movies", + "episodes", "songs", "albums", - "movies", "artists", - "tvShows", - ]); - expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual([ - "songs", - "albums", - "movies", - "artists", - "tvShows", - ]); + "people", + ]; + expect(get(searchGroupOrder)).toEqual(expected); + expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual(expected); // Simulate a fresh app start reading the same storage. vi.resetModules(); const reloaded = await import("./searchGroupOrder"); - expect(get(reloaded.searchGroupOrder)).toEqual([ - "songs", - "albums", - "movies", - "artists", - "tvShows", - ]); + expect(get(reloaded.searchGroupOrder)).toEqual(expected); }); it("persists a drag reorder", async () => { const { searchGroupOrder } = await import("./searchGroupOrder"); + // Drag "albums" (index 4) to the front. searchGroupOrder.reorder(4, 0); expect(get(searchGroupOrder)).toEqual([ - "tvShows", - "songs", "albums", - "artists", + "shows", + "episodes", "movies", + "songs", + "artists", + "people", ]); }); it("resets to the shipped default", async () => { const { searchGroupOrder } = await import("./searchGroupOrder"); - searchGroupOrder.move("tvShows", -1); + searchGroupOrder.move("movies", -1); searchGroupOrder.reset(); expect(get(searchGroupOrder)).toEqual([...DEFAULT_GROUP_ORDER]); }); @@ -127,10 +142,12 @@ describe("searchGroupOrder", () => { searchGroupOrder.set(["movies", "podcasts"] as never); expect(get(searchGroupOrder)).toEqual([ "movies", + "shows", + "episodes", "songs", "albums", "artists", - "tvShows", + "people", ]); }); }); diff --git a/src/lib/utils/searchScope.test.ts b/src/lib/utils/searchScope.test.ts index 3740e28f..96b52110 100644 --- a/src/lib/utils/searchScope.test.ts +++ b/src/lib/utils/searchScope.test.ts @@ -92,9 +92,11 @@ describe("normalizeGroupOrder", () => { expect(normalizeGroupOrder(["movies", "podcasts", "songs"])).toEqual([ "movies", "songs", + "shows", + "episodes", "albums", "artists", - "tvShows", + "people", ]); }); @@ -103,9 +105,11 @@ describe("normalizeGroupOrder", () => { expect(normalizeGroupOrder(["movies", "songs"])).toEqual([ "movies", "songs", + "shows", + "episodes", "albums", "artists", - "tvShows", + "people", ]); }); @@ -113,34 +117,78 @@ describe("normalizeGroupOrder", () => { expect(normalizeGroupOrder(["songs", "songs", "movies"])).toEqual([ "songs", "movies", + "shows", + "episodes", "albums", "artists", - "tvShows", + "people", ]); }); it("preserves a complete valid order unchanged", () => { - const order: SearchGroupId[] = ["tvShows", "movies", "artists", "albums", "songs"]; + const order: SearchGroupId[] = [ + "episodes", + "shows", + "movies", + "artists", + "albums", + "songs", + "people", + ]; expect(normalizeGroupOrder(order)).toEqual(order); }); + + it("expands a stored `tvShows` into shows + episodes in place", () => { + // Migration: the old combined group split, and a user who put TV first + // must still get TV first rather than appended at the bottom. + expect(normalizeGroupOrder(["tvShows", "movies"])).toEqual([ + "shows", + "episodes", + "movies", + "songs", + "albums", + "artists", + "people", + ]); + }); }); describe("groupsForScope", () => { it("returns every group in saved order for the all scope", () => { - expect(groupsForScope("all", ["movies", "songs", "tvShows", "albums", "artists"])).toEqual([ - "movies", - "songs", - "tvShows", - "albums", - "artists", - ]); + expect( + groupsForScope("all", [ + "movies", + "songs", + "shows", + "episodes", + "albums", + "artists", + "people", + ]) + ).toEqual(["movies", "songs", "shows", "episodes", "albums", "artists", "people"]); }); it("keeps only in-scope groups, in saved order", () => { - const order: SearchGroupId[] = ["artists", "movies", "albums", "tvShows", "songs"]; + const order: SearchGroupId[] = [ + "artists", + "movies", + "albums", + "episodes", + "shows", + "songs", + "people", + ]; expect(groupsForScope("music", order)).toEqual(["artists", "albums", "songs"]); expect(groupsForScope("movies", order)).toEqual(["movies"]); - expect(groupsForScope("tv", order)).toEqual(["tvShows"]); + expect(groupsForScope("tv", order)).toEqual(["episodes", "shows"]); + }); + + it("surfaces people only under the all scope", () => { + // Cast/crew cut across music, film and TV, so no narrow scope claims them. + expect(groupsForScope("all", DEFAULT_GROUP_ORDER)).toContain("people"); + expect(groupsForScope("music", DEFAULT_GROUP_ORDER)).not.toContain("people"); + expect(groupsForScope("tv", DEFAULT_GROUP_ORDER)).not.toContain("people"); + expect(groupsForScope("movies", DEFAULT_GROUP_ORDER)).not.toContain("people"); }); }); @@ -156,13 +204,29 @@ describe("composeSearchGroups", () => { it("renders groups in the configured order", () => { const groups = composeSearchGroups(results, "all", [ - "tvShows", + "shows", + "episodes", "movies", "songs", "albums", "artists", + "people", ]); - expect(groups.map((g) => g.id)).toEqual(["tvShows", "movies", "songs", "albums"]); + expect(groups.map((g) => g.id)).toEqual([ + "shows", + "episodes", + "movies", + "songs", + "albums", + "people", + ]); + }); + + it("puts shows ahead of episodes by default", () => { + // Searching a show's name should surface the show itself first, not an + // arbitrary episode of it. + const ids = composeSearchGroups(results, "tv", DEFAULT_GROUP_ORDER).map((g) => g.id); + expect(ids).toEqual(["shows", "episodes"]); }); it("omits empty groups", () => { @@ -177,27 +241,44 @@ describe("composeSearchGroups", () => { "albums", ]); expect(composeSearchGroups(results, "tv", DEFAULT_GROUP_ORDER).map((g) => g.id)).toEqual([ - "tvShows", + "shows", + "episodes", ]); }); - it("groups series and episodes together under tvShows", () => { + it("separates series and episodes into their own groups", () => { const groups = composeSearchGroups(results, "tv", DEFAULT_GROUP_ORDER); - expect(groups[0].items.map((i) => i.id)).toEqual(["4", "5"]); + expect(groups.find((g) => g.id === "shows")?.items.map((i) => i.id)).toEqual(["4"]); + expect(groups.find((g) => g.id === "episodes")?.items.map((i) => i.id)).toEqual(["5"]); + }); + + it("surfaces people so an actor search reaches their bio", () => { + // Person items were previously returned by the backend and silently dropped. + const all = composeSearchGroups(results, "all", DEFAULT_GROUP_ORDER); + expect(all.find((g) => g.id === "people")?.items.map((i) => i.id)).toEqual(["6"]); }); it("ignores item types that belong to no group", () => { - const all = composeSearchGroups(results, "all", DEFAULT_GROUP_ORDER); - expect(all.flatMap((g) => g.items).map((i) => i.id)).not.toContain("6"); + const withFolder = [...results, { id: "7", type: "CollectionFolder" }]; + const all = composeSearchGroups(withFolder, "all", DEFAULT_GROUP_ORDER); + expect(all.flatMap((g) => g.items).map((i) => i.id)).not.toContain("7"); }); it("narrowing then widening restores the full arrangement", () => { // Scope is a filter over the saved order, never a rewrite of it. - const order: SearchGroupId[] = ["tvShows", "songs", "movies", "albums", "artists"]; + const order: SearchGroupId[] = [ + "shows", + "songs", + "movies", + "albums", + "artists", + "episodes", + "people", + ]; const wide = composeSearchGroups(results, "all", order).map((g) => g.id); composeSearchGroups(results, "music", order); expect(composeSearchGroups(results, "all", order).map((g) => g.id)).toEqual(wide); - expect(wide).toEqual(["tvShows", "songs", "movies", "albums"]); + expect(wide).toEqual(["shows", "songs", "movies", "albums", "episodes", "people"]); }); it("survives a stored order containing an unknown id", () => { @@ -205,7 +286,14 @@ describe("composeSearchGroups", () => { "podcasts", "movies", ] as unknown as SearchGroupId[]); - expect(groups.map((g) => g.id)).toEqual(["movies", "songs", "albums", "tvShows"]); + expect(groups.map((g) => g.id)).toEqual([ + "movies", + "shows", + "episodes", + "songs", + "albums", + "people", + ]); }); it("handles items with a missing type", () => { @@ -219,7 +307,7 @@ describe("composeSearchGroups", () => { }); describe("moveGroup", () => { - const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "tvShows"]; + const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "shows"]; it("moves a group up", () => { expect(moveGroup(order, "artists", -1)).toEqual([ @@ -227,7 +315,7 @@ describe("moveGroup", () => { "artists", "albums", "movies", - "tvShows", + "shows", ]); }); @@ -237,13 +325,13 @@ describe("moveGroup", () => { "songs", "artists", "movies", - "tvShows", + "shows", ]); }); it("is a no-op at the boundaries", () => { expect(moveGroup(order, "songs", -1)).toEqual(order); - expect(moveGroup(order, "tvShows", 1)).toEqual(order); + expect(moveGroup(order, "shows", 1)).toEqual(order); }); it("is a no-op for an unknown id", () => { @@ -258,18 +346,18 @@ describe("moveGroup", () => { }); describe("reorderGroups", () => { - const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "tvShows"]; + const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "shows"]; it("moves an item from one index to another", () => { expect(reorderGroups(order, 0, 4)).toEqual([ "albums", "artists", "movies", - "tvShows", + "shows", "songs", ]); expect(reorderGroups(order, 4, 0)).toEqual([ - "tvShows", + "shows", "songs", "albums", "artists", @@ -282,4 +370,4 @@ describe("reorderGroups", () => { expect(reorderGroups(order, -1, 2)).toEqual(order); expect(reorderGroups(order, 0, 9)).toEqual(order); }); -}); +}); \ No newline at end of file diff --git a/src/lib/utils/searchScope.ts b/src/lib/utils/searchScope.ts index dc8e7b25..f1e57214 100644 --- a/src/lib/utils/searchScope.ts +++ b/src/lib/utils/searchScope.ts @@ -66,47 +66,96 @@ export function resolveSearchScope(pathname: string): SearchScope { // Result groups // --------------------------------------------------------------------------- -export type SearchGroupId = "songs" | "albums" | "artists" | "movies" | "tvShows"; +export type SearchGroupId = + | "shows" + | "episodes" + | "movies" + | "songs" + | "albums" + | "artists" + | "people"; -/** Shipped default order, per the spec. */ +/** + * Shipped default order. + * + * TRACES: UR-060 | DR-091 + * + * Containers lead the kinds they contain — a show above its episodes, an album + * above nothing (songs are ranked separately) — which matches how people search: + * you look for the show, not an arbitrary episode of it. `people` sits last as + * a peripheral match; it exists so searching an actor's name reaches their bio + * page rather than silently dropping the result. + */ export const DEFAULT_GROUP_ORDER: readonly SearchGroupId[] = [ + "shows", + "episodes", + "movies", "songs", "albums", "artists", - "movies", - "tvShows", + "people", ]; export const GROUP_LABELS: Record = { + shows: "TV Shows", + episodes: "Episodes", + movies: "Movies", songs: "Songs", albums: "Albums", artists: "Artists", - movies: "Movies", - tvShows: "TV Shows", + people: "People", }; -/** Which scopes each group belongs to (`all` always includes everything). */ -const GROUP_SCOPE: Record> = { +/** + * Which scopes each group belongs to (`all` always includes everything). + * + * `people` maps to no narrow scope: cast/crew cut across music, film and TV, so + * it surfaces only under All rather than being forced into one of them. + */ +const GROUP_SCOPE: Record | null> = { + shows: "tv", + episodes: "tv", + movies: "movies", songs: "music", albums: "music", artists: "music", - movies: "movies", - tvShows: "tv", + people: null, }; /** Item types that fall into each group. */ const GROUP_ITEM_TYPES: Record = { + shows: ["Series"], + episodes: ["Episode"], + movies: ["Movie"], songs: ["Audio"], albums: ["MusicAlbum"], artists: ["MusicArtist"], - movies: ["Movie"], - tvShows: ["Series", "Episode"], + people: ["Person"], }; export function groupItemTypes(group: SearchGroupId): string[] { return [...GROUP_ITEM_TYPES[group]]; } +/** + * Stored group ids that no longer exist, mapped to the ids that replaced them. + * + * `tvShows` was one group holding both Series and Episode; it split so a show + * can outrank its own episodes. Expanding in place preserves the position the + * user chose for it. + * + * TRACES: UR-060 | DR-091 + */ +const RETIRED_GROUP_IDS: Record = { + tvShows: ["shows", "episodes"], +}; + +/** Resolve a stored id to the live id(s) it corresponds to, or none if unknown. */ +function migrateGroupId(id: string, known: Set): SearchGroupId[] { + if (known.has(id)) return [id as SearchGroupId]; + return RETIRED_GROUP_IDS[id] ?? []; +} + /** * Normalise a stored order into a usable one. * @@ -123,11 +172,15 @@ export function normalizeGroupOrder(stored: unknown): SearchGroupId[] { if (Array.isArray(stored)) { for (const id of stored) { - if (typeof id !== "string" || !known.has(id)) continue; - const groupId = id as SearchGroupId; - if (seen.has(groupId)) continue; - seen.add(groupId); - order.push(groupId); + if (typeof id !== "string") continue; + // Retired ids expand in place rather than being dropped, so a user who + // dragged the old combined "TV Shows" group to the top keeps TV at the + // top instead of having shows/episodes appended to the bottom. + for (const groupId of migrateGroupId(id, known)) { + if (seen.has(groupId)) continue; + seen.add(groupId); + order.push(groupId); + } } } @@ -143,6 +196,8 @@ export function groupsForScope( scope: SearchScope, order: readonly SearchGroupId[] = DEFAULT_GROUP_ORDER ): SearchGroupId[] { + // A `null` GROUP_SCOPE (people) belongs to no narrow scope, so it survives + // only under `all` — the `=== scope` test already excludes it elsewhere. return normalizeGroupOrder(order as SearchGroupId[]).filter( (id) => scope === "all" || GROUP_SCOPE[id] === scope );