feat(search): rank results by match quality and split TV/People groups
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.
This commit is contained in:
@@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use tauri::{AppHandle, Emitter, State};
|
use tauri::{AppHandle, Emitter, State};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::domain::rank_search_results;
|
||||||
use crate::jellyfin::HttpClient;
|
use crate::jellyfin::HttpClient;
|
||||||
use crate::repository::{
|
use crate::repository::{
|
||||||
types::*, HybridRepository, MediaRepository, OfflineRepository, OnlineRepository,
|
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
|
// Phase 1: instant local results from the cache (downloaded content) so the
|
||||||
// UI can render immediately while the server is still being queried.
|
// 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())
|
.search_cache_only(&query, options.clone())
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|e| {
|
.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,
|
// 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
|
// and push the union to the frontend via a `search-event`. Tagged with
|
||||||
// `request_id` so the frontend can discard results from superseded queries.
|
// `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 {
|
tauri::async_runtime::spawn(async move {
|
||||||
match repo_bg.search_server_only(&query, options).await {
|
match repo_bg.search_server_only(&query, options).await {
|
||||||
Ok(server_result) => {
|
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 {
|
let event = SearchUpdateEvent {
|
||||||
request_id,
|
request_id,
|
||||||
result: merged,
|
result: merged,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
pub mod from_jellyfin;
|
pub mod from_jellyfin;
|
||||||
pub mod media;
|
pub mod media;
|
||||||
|
pub mod search_rank;
|
||||||
|
|
||||||
pub use from_jellyfin::{kind_from_jellyfin, stream_kind_from_jellyfin, ticks_to_ms};
|
pub use from_jellyfin::{kind_from_jellyfin, stream_kind_from_jellyfin, ticks_to_ms};
|
||||||
pub use media::{MediaKind, StreamKind};
|
pub use media::{MediaKind, StreamKind};
|
||||||
|
pub use search_rank::rank_search_results;
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
<MediaCard
|
<MediaCard
|
||||||
{item}
|
{item}
|
||||||
size="medium"
|
size="medium"
|
||||||
showProgress={group.id !== "artists"}
|
showProgress={group.id !== "artists" && group.id !== "people"}
|
||||||
onclick={() => onItemClick?.(item)}
|
onclick={() => onItemClick?.(item)}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -42,15 +42,33 @@ describe("searchGroupOrder", () => {
|
|||||||
it("loads a stored order", async () => {
|
it("loads a stored order", async () => {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
STORAGE_KEY,
|
STORAGE_KEY,
|
||||||
JSON.stringify(["tvShows", "movies", "songs", "albums", "artists"])
|
JSON.stringify(["episodes", "shows", "movies", "songs", "albums", "artists", "people"])
|
||||||
);
|
);
|
||||||
const { searchGroupOrder } = await import("./searchGroupOrder");
|
const { searchGroupOrder } = await import("./searchGroupOrder");
|
||||||
expect(get(searchGroupOrder)).toEqual([
|
expect(get(searchGroupOrder)).toEqual([
|
||||||
"tvShows",
|
"episodes",
|
||||||
|
"shows",
|
||||||
"movies",
|
"movies",
|
||||||
"songs",
|
"songs",
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"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");
|
const { searchGroupOrder } = await import("./searchGroupOrder");
|
||||||
expect(get(searchGroupOrder)).toEqual([
|
expect(get(searchGroupOrder)).toEqual([
|
||||||
"movies",
|
"movies",
|
||||||
|
"shows",
|
||||||
|
"episodes",
|
||||||
"songs",
|
"songs",
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"artists",
|
||||||
"tvShows",
|
"people",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -74,50 +94,45 @@ describe("searchGroupOrder", () => {
|
|||||||
|
|
||||||
it("persists a move so the order survives a restart", async () => {
|
it("persists a move so the order survives a restart", async () => {
|
||||||
const { searchGroupOrder } = await import("./searchGroupOrder");
|
const { searchGroupOrder } = await import("./searchGroupOrder");
|
||||||
|
// Default is shows, episodes, movies, songs, … — move movies up one.
|
||||||
searchGroupOrder.move("movies", -1);
|
searchGroupOrder.move("movies", -1);
|
||||||
|
|
||||||
expect(get(searchGroupOrder)).toEqual([
|
const expected = [
|
||||||
|
"shows",
|
||||||
|
"movies",
|
||||||
|
"episodes",
|
||||||
"songs",
|
"songs",
|
||||||
"albums",
|
"albums",
|
||||||
"movies",
|
|
||||||
"artists",
|
"artists",
|
||||||
"tvShows",
|
"people",
|
||||||
]);
|
];
|
||||||
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual([
|
expect(get(searchGroupOrder)).toEqual(expected);
|
||||||
"songs",
|
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual(expected);
|
||||||
"albums",
|
|
||||||
"movies",
|
|
||||||
"artists",
|
|
||||||
"tvShows",
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Simulate a fresh app start reading the same storage.
|
// Simulate a fresh app start reading the same storage.
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
const reloaded = await import("./searchGroupOrder");
|
const reloaded = await import("./searchGroupOrder");
|
||||||
expect(get(reloaded.searchGroupOrder)).toEqual([
|
expect(get(reloaded.searchGroupOrder)).toEqual(expected);
|
||||||
"songs",
|
|
||||||
"albums",
|
|
||||||
"movies",
|
|
||||||
"artists",
|
|
||||||
"tvShows",
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("persists a drag reorder", async () => {
|
it("persists a drag reorder", async () => {
|
||||||
const { searchGroupOrder } = await import("./searchGroupOrder");
|
const { searchGroupOrder } = await import("./searchGroupOrder");
|
||||||
|
// Drag "albums" (index 4) to the front.
|
||||||
searchGroupOrder.reorder(4, 0);
|
searchGroupOrder.reorder(4, 0);
|
||||||
expect(get(searchGroupOrder)).toEqual([
|
expect(get(searchGroupOrder)).toEqual([
|
||||||
"tvShows",
|
|
||||||
"songs",
|
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"shows",
|
||||||
|
"episodes",
|
||||||
"movies",
|
"movies",
|
||||||
|
"songs",
|
||||||
|
"artists",
|
||||||
|
"people",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resets to the shipped default", async () => {
|
it("resets to the shipped default", async () => {
|
||||||
const { searchGroupOrder } = await import("./searchGroupOrder");
|
const { searchGroupOrder } = await import("./searchGroupOrder");
|
||||||
searchGroupOrder.move("tvShows", -1);
|
searchGroupOrder.move("movies", -1);
|
||||||
searchGroupOrder.reset();
|
searchGroupOrder.reset();
|
||||||
expect(get(searchGroupOrder)).toEqual([...DEFAULT_GROUP_ORDER]);
|
expect(get(searchGroupOrder)).toEqual([...DEFAULT_GROUP_ORDER]);
|
||||||
});
|
});
|
||||||
@@ -127,10 +142,12 @@ describe("searchGroupOrder", () => {
|
|||||||
searchGroupOrder.set(["movies", "podcasts"] as never);
|
searchGroupOrder.set(["movies", "podcasts"] as never);
|
||||||
expect(get(searchGroupOrder)).toEqual([
|
expect(get(searchGroupOrder)).toEqual([
|
||||||
"movies",
|
"movies",
|
||||||
|
"shows",
|
||||||
|
"episodes",
|
||||||
"songs",
|
"songs",
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"artists",
|
||||||
"tvShows",
|
"people",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -92,9 +92,11 @@ describe("normalizeGroupOrder", () => {
|
|||||||
expect(normalizeGroupOrder(["movies", "podcasts", "songs"])).toEqual([
|
expect(normalizeGroupOrder(["movies", "podcasts", "songs"])).toEqual([
|
||||||
"movies",
|
"movies",
|
||||||
"songs",
|
"songs",
|
||||||
|
"shows",
|
||||||
|
"episodes",
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"artists",
|
||||||
"tvShows",
|
"people",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -103,9 +105,11 @@ describe("normalizeGroupOrder", () => {
|
|||||||
expect(normalizeGroupOrder(["movies", "songs"])).toEqual([
|
expect(normalizeGroupOrder(["movies", "songs"])).toEqual([
|
||||||
"movies",
|
"movies",
|
||||||
"songs",
|
"songs",
|
||||||
|
"shows",
|
||||||
|
"episodes",
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"artists",
|
||||||
"tvShows",
|
"people",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -113,34 +117,78 @@ describe("normalizeGroupOrder", () => {
|
|||||||
expect(normalizeGroupOrder(["songs", "songs", "movies"])).toEqual([
|
expect(normalizeGroupOrder(["songs", "songs", "movies"])).toEqual([
|
||||||
"songs",
|
"songs",
|
||||||
"movies",
|
"movies",
|
||||||
|
"shows",
|
||||||
|
"episodes",
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"artists",
|
||||||
"tvShows",
|
"people",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves a complete valid order unchanged", () => {
|
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);
|
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", () => {
|
describe("groupsForScope", () => {
|
||||||
it("returns every group in saved order for the all scope", () => {
|
it("returns every group in saved order for the all scope", () => {
|
||||||
expect(groupsForScope("all", ["movies", "songs", "tvShows", "albums", "artists"])).toEqual([
|
expect(
|
||||||
|
groupsForScope("all", [
|
||||||
"movies",
|
"movies",
|
||||||
"songs",
|
"songs",
|
||||||
"tvShows",
|
"shows",
|
||||||
|
"episodes",
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"artists",
|
||||||
]);
|
"people",
|
||||||
|
])
|
||||||
|
).toEqual(["movies", "songs", "shows", "episodes", "albums", "artists", "people"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps only in-scope groups, in saved order", () => {
|
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("music", order)).toEqual(["artists", "albums", "songs"]);
|
||||||
expect(groupsForScope("movies", order)).toEqual(["movies"]);
|
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", () => {
|
it("renders groups in the configured order", () => {
|
||||||
const groups = composeSearchGroups(results, "all", [
|
const groups = composeSearchGroups(results, "all", [
|
||||||
"tvShows",
|
"shows",
|
||||||
|
"episodes",
|
||||||
"movies",
|
"movies",
|
||||||
"songs",
|
"songs",
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"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", () => {
|
it("omits empty groups", () => {
|
||||||
@@ -177,27 +241,44 @@ describe("composeSearchGroups", () => {
|
|||||||
"albums",
|
"albums",
|
||||||
]);
|
]);
|
||||||
expect(composeSearchGroups(results, "tv", DEFAULT_GROUP_ORDER).map((g) => g.id)).toEqual([
|
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);
|
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", () => {
|
it("ignores item types that belong to no group", () => {
|
||||||
const all = composeSearchGroups(results, "all", DEFAULT_GROUP_ORDER);
|
const withFolder = [...results, { id: "7", type: "CollectionFolder" }];
|
||||||
expect(all.flatMap((g) => g.items).map((i) => i.id)).not.toContain("6");
|
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", () => {
|
it("narrowing then widening restores the full arrangement", () => {
|
||||||
// Scope is a filter over the saved order, never a rewrite of it.
|
// 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);
|
const wide = composeSearchGroups(results, "all", order).map((g) => g.id);
|
||||||
composeSearchGroups(results, "music", order);
|
composeSearchGroups(results, "music", order);
|
||||||
expect(composeSearchGroups(results, "all", order).map((g) => g.id)).toEqual(wide);
|
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", () => {
|
it("survives a stored order containing an unknown id", () => {
|
||||||
@@ -205,7 +286,14 @@ describe("composeSearchGroups", () => {
|
|||||||
"podcasts",
|
"podcasts",
|
||||||
"movies",
|
"movies",
|
||||||
] as unknown as SearchGroupId[]);
|
] 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", () => {
|
it("handles items with a missing type", () => {
|
||||||
@@ -219,7 +307,7 @@ describe("composeSearchGroups", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("moveGroup", () => {
|
describe("moveGroup", () => {
|
||||||
const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "tvShows"];
|
const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "shows"];
|
||||||
|
|
||||||
it("moves a group up", () => {
|
it("moves a group up", () => {
|
||||||
expect(moveGroup(order, "artists", -1)).toEqual([
|
expect(moveGroup(order, "artists", -1)).toEqual([
|
||||||
@@ -227,7 +315,7 @@ describe("moveGroup", () => {
|
|||||||
"artists",
|
"artists",
|
||||||
"albums",
|
"albums",
|
||||||
"movies",
|
"movies",
|
||||||
"tvShows",
|
"shows",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -237,13 +325,13 @@ describe("moveGroup", () => {
|
|||||||
"songs",
|
"songs",
|
||||||
"artists",
|
"artists",
|
||||||
"movies",
|
"movies",
|
||||||
"tvShows",
|
"shows",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is a no-op at the boundaries", () => {
|
it("is a no-op at the boundaries", () => {
|
||||||
expect(moveGroup(order, "songs", -1)).toEqual(order);
|
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", () => {
|
it("is a no-op for an unknown id", () => {
|
||||||
@@ -258,18 +346,18 @@ describe("moveGroup", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("reorderGroups", () => {
|
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", () => {
|
it("moves an item from one index to another", () => {
|
||||||
expect(reorderGroups(order, 0, 4)).toEqual([
|
expect(reorderGroups(order, 0, 4)).toEqual([
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"artists",
|
||||||
"movies",
|
"movies",
|
||||||
"tvShows",
|
"shows",
|
||||||
"songs",
|
"songs",
|
||||||
]);
|
]);
|
||||||
expect(reorderGroups(order, 4, 0)).toEqual([
|
expect(reorderGroups(order, 4, 0)).toEqual([
|
||||||
"tvShows",
|
"shows",
|
||||||
"songs",
|
"songs",
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"artists",
|
||||||
|
|||||||
@@ -66,47 +66,96 @@ export function resolveSearchScope(pathname: string): SearchScope {
|
|||||||
// Result groups
|
// 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[] = [
|
export const DEFAULT_GROUP_ORDER: readonly SearchGroupId[] = [
|
||||||
|
"shows",
|
||||||
|
"episodes",
|
||||||
|
"movies",
|
||||||
"songs",
|
"songs",
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"artists",
|
||||||
"movies",
|
"people",
|
||||||
"tvShows",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const GROUP_LABELS: Record<SearchGroupId, string> = {
|
export const GROUP_LABELS: Record<SearchGroupId, string> = {
|
||||||
|
shows: "TV Shows",
|
||||||
|
episodes: "Episodes",
|
||||||
|
movies: "Movies",
|
||||||
songs: "Songs",
|
songs: "Songs",
|
||||||
albums: "Albums",
|
albums: "Albums",
|
||||||
artists: "Artists",
|
artists: "Artists",
|
||||||
movies: "Movies",
|
people: "People",
|
||||||
tvShows: "TV Shows",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Which scopes each group belongs to (`all` always includes everything). */
|
/**
|
||||||
const GROUP_SCOPE: Record<SearchGroupId, Exclude<SearchScope, "all">> = {
|
* 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<SearchGroupId, Exclude<SearchScope, "all"> | null> = {
|
||||||
|
shows: "tv",
|
||||||
|
episodes: "tv",
|
||||||
|
movies: "movies",
|
||||||
songs: "music",
|
songs: "music",
|
||||||
albums: "music",
|
albums: "music",
|
||||||
artists: "music",
|
artists: "music",
|
||||||
movies: "movies",
|
people: null,
|
||||||
tvShows: "tv",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Item types that fall into each group. */
|
/** Item types that fall into each group. */
|
||||||
const GROUP_ITEM_TYPES: Record<SearchGroupId, string[]> = {
|
const GROUP_ITEM_TYPES: Record<SearchGroupId, string[]> = {
|
||||||
|
shows: ["Series"],
|
||||||
|
episodes: ["Episode"],
|
||||||
|
movies: ["Movie"],
|
||||||
songs: ["Audio"],
|
songs: ["Audio"],
|
||||||
albums: ["MusicAlbum"],
|
albums: ["MusicAlbum"],
|
||||||
artists: ["MusicArtist"],
|
artists: ["MusicArtist"],
|
||||||
movies: ["Movie"],
|
people: ["Person"],
|
||||||
tvShows: ["Series", "Episode"],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function groupItemTypes(group: SearchGroupId): string[] {
|
export function groupItemTypes(group: SearchGroupId): string[] {
|
||||||
return [...GROUP_ITEM_TYPES[group]];
|
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<string, SearchGroupId[]> = {
|
||||||
|
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<string>): SearchGroupId[] {
|
||||||
|
if (known.has(id)) return [id as SearchGroupId];
|
||||||
|
return RETIRED_GROUP_IDS[id] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalise a stored order into a usable one.
|
* Normalise a stored order into a usable one.
|
||||||
*
|
*
|
||||||
@@ -123,13 +172,17 @@ export function normalizeGroupOrder(stored: unknown): SearchGroupId[] {
|
|||||||
|
|
||||||
if (Array.isArray(stored)) {
|
if (Array.isArray(stored)) {
|
||||||
for (const id of stored) {
|
for (const id of stored) {
|
||||||
if (typeof id !== "string" || !known.has(id)) continue;
|
if (typeof id !== "string") continue;
|
||||||
const groupId = id as SearchGroupId;
|
// 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;
|
if (seen.has(groupId)) continue;
|
||||||
seen.add(groupId);
|
seen.add(groupId);
|
||||||
order.push(groupId);
|
order.push(groupId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const id of DEFAULT_GROUP_ORDER) {
|
for (const id of DEFAULT_GROUP_ORDER) {
|
||||||
if (!seen.has(id)) order.push(id);
|
if (!seen.has(id)) order.push(id);
|
||||||
@@ -143,6 +196,8 @@ export function groupsForScope(
|
|||||||
scope: SearchScope,
|
scope: SearchScope,
|
||||||
order: readonly SearchGroupId[] = DEFAULT_GROUP_ORDER
|
order: readonly SearchGroupId[] = DEFAULT_GROUP_ORDER
|
||||||
): SearchGroupId[] {
|
): 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(
|
return normalizeGroupOrder(order as SearchGroupId[]).filter(
|
||||||
(id) => scope === "all" || GROUP_SCOPE[id] === scope
|
(id) => scope === "all" || GROUP_SCOPE[id] === scope
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user