🏗️ Build and Test JellyTau / Run Tests (push) Successful in 16m59s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m36s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Successful in 5m9s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 8m54s
Build & Release / Build Linux (push) Successful in 18m49s
Build & Release / Build Windows (push) Successful in 14m4s
Build & Release / Build Android (push) Successful in 30m17s
Build & Release / Create Release (push) Successful in 18s
Opening a series dumped the viewer at the top of season 1, and its Play button played nothing at all: it resolved `$libraryItems[0]` — the first *season* by SortName — and navigated to `/player/<seasonId>`, which the player route bounced straight back to `/library/<seasonId>`. The backend could already answer "where is this viewer in this show": `repository_get_next_up_episodes` has accepted a `series_id` since it was written and no caller had ever passed one. Backend (DR-101, DR-106) - `repository/series_progress.rs`: `pick_current_episode` — in progress, else Next Up, else first unwatched, else the premiere. The third rung is the offline path, where Next Up is always empty. `sort_series_order` puts specials (season 0) after the numbered seasons. - `repository_get_series_episodes` takes over the season fan-out and the flat-series fallback, which were domain knowledge living in the frontend. - `clear_watch_history` maps to Jellyfin's mark-unplayed (recursive over a container, also zeroes resume). Offline it refuses rather than diverging state the next sync would undo. Frontend (DR-102, DR-103, DR-104, DR-107) - Seasons collapse; only the current one is expanded, and the current episode is badged and scrolled into view. - Hero button reads `Resume S2E4` / `Play S1E1` and opens that episode's focus view, where Play commits (ux-flows §5B.5). - Seasons are no longer a destination: `/library/<seasonId>` redirects to `/library/<seriesId>#season-N`, and every inbound link follows. - The "More Episodes" strip spans the whole series, so a season finale offers the next premiere instead of dead-ending (§5B.2). - Clear-history buttons on the series hero and each season header. Routes (DR-105) - `/library/tv` and `/library/movies` absorb their all-titles and genres pages as `?view=` tabs; the four legacy routes redirect. 6 video routes become 2, and `/library/shows/genres` stops being the odd one out. Logic extracted to `seriesNavigation.ts`, `episodeStrip.ts` and `libraryView.ts` so it is unit-tested rather than buried in components. Spec: docs/specs/series-current-episode-navigation.md
285 lines
9.3 KiB
TypeScript
285 lines
9.3 KiB
TypeScript
// Search scoping and result-group ordering.
|
|
//
|
|
// Two independent axes govern how search results are presented:
|
|
// - *scope* narrows which item types are requested from the repository,
|
|
// - *group order* decides the sequence the surviving groups render in.
|
|
// Neither one rewrites the other: narrowing to Music and widening back to All
|
|
// restores the user's saved arrangement untouched.
|
|
//
|
|
// TRACES: UR-049, UR-050 | DR-063, DR-066, DR-067
|
|
|
|
// Sourced from Rust via the generated bindings — the backend owns what a scope
|
|
// *means* (which Jellyfin item types it covers). Naming an opaque variant is
|
|
// presentation; knowing its expansion is domain vocabulary and stays in Rust.
|
|
export type { SearchScope } from "$lib/api/bindings";
|
|
import type { SearchScope } from "$lib/api/bindings";
|
|
|
|
export const SEARCH_SCOPES: readonly SearchScope[] = ["all", "music", "movies", "tv"];
|
|
|
|
export const SCOPE_LABELS: Record<SearchScope, string> = {
|
|
all: "All",
|
|
music: "Music",
|
|
movies: "Movies",
|
|
tv: "TV",
|
|
};
|
|
|
|
// NOTE: the scope → Jellyfin item-type mapping deliberately does NOT live here.
|
|
// It is domain vocabulary and lives in Rust (`SearchScope::item_types()` in
|
|
// repository/types.rs); the frontend sends the opaque scope and the backend
|
|
// expands it. Re-introducing a `{ music: ["MusicAlbum", …] }` table in this file
|
|
// is the boundary leak documented in docs/specs/scoped-search-boundary.md.
|
|
|
|
/**
|
|
* Resolve the scope a search started from a given route should default to.
|
|
* Pure — takes a pathname, touches no DOM, so it unit-tests directly.
|
|
*
|
|
* TRACES: UR-049 | DR-063
|
|
*/
|
|
export function resolveSearchScope(pathname: string): SearchScope {
|
|
// Tolerate query strings, hashes and trailing slashes.
|
|
const path = pathname.split(/[?#]/)[0].replace(/\/+$/, "") || "/";
|
|
|
|
if (path === "/library/music" || path.startsWith("/library/music/")) return "music";
|
|
if (path === "/library/movies" || path.startsWith("/library/movies/")) return "movies";
|
|
if (path === "/library/tv" || path.startsWith("/library/tv/")) return "tv";
|
|
// `/library/shows/*` is a legacy TV route that now redirects into
|
|
// `/library/tv?view=genres` (DR-105). Kept so a search typed on the URL
|
|
// before the redirect lands still scopes to TV.
|
|
if (path === "/library/shows" || path.startsWith("/library/shows/")) return "tv";
|
|
|
|
return "all";
|
|
}
|
|
|
|
/**
|
|
* The URL of the single search surface for a query + scope.
|
|
*
|
|
* `/search` is the *only* route that renders results, so every other search
|
|
* affordance (the desktop header bar) is a navigator to this URL rather than a
|
|
* second result renderer. The `all` scope is the page's own default, so it is
|
|
* omitted to keep shared/back-navigated URLs clean.
|
|
*
|
|
* TRACES: UR-049 | DR-063
|
|
*/
|
|
export function searchRouteUrl(query: string, scope: SearchScope): string {
|
|
const trimmed = query.trim();
|
|
if (!trimmed) return "/search";
|
|
|
|
const params = new URLSearchParams({ q: trimmed });
|
|
if (scope !== "all") params.set("scope", scope);
|
|
// URLSearchParams renders spaces as "+", valid in a query but noisier to
|
|
// read; %20 is equally valid and matches how the app builds other links.
|
|
return `/search?${params.toString().replace(/\+/g, "%20")}`;
|
|
}
|
|
|
|
/**
|
|
* Whether a search typed on `pathname` must navigate to `/search` to be seen.
|
|
*
|
|
* True for every route except `/search` itself: no other page renders
|
|
* `searchResults`, so a search performed there is invisible. Guarding on
|
|
* `/search` keeps typing from pushing a history entry per keystroke.
|
|
*
|
|
* TRACES: UR-049 | DR-063
|
|
*/
|
|
export function shouldNavigateToSearch(pathname: string, query: string): boolean {
|
|
if (!query.trim()) return false;
|
|
const path = pathname.split(/[?#]/)[0].replace(/\/+$/, "") || "/";
|
|
return path !== "/search";
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Result groups
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type SearchGroupId =
|
|
| "shows"
|
|
| "episodes"
|
|
| "movies"
|
|
| "songs"
|
|
| "albums"
|
|
| "artists"
|
|
| "people";
|
|
|
|
/**
|
|
* 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",
|
|
"people",
|
|
];
|
|
|
|
export const GROUP_LABELS: Record<SearchGroupId, string> = {
|
|
shows: "TV Shows",
|
|
episodes: "Episodes",
|
|
movies: "Movies",
|
|
songs: "Songs",
|
|
albums: "Albums",
|
|
artists: "Artists",
|
|
people: "People",
|
|
};
|
|
|
|
/**
|
|
* 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",
|
|
albums: "music",
|
|
artists: "music",
|
|
people: null,
|
|
};
|
|
|
|
/** Item types that fall into each group. */
|
|
const GROUP_ITEM_TYPES: Record<SearchGroupId, string[]> = {
|
|
shows: ["Series"],
|
|
episodes: ["Episode"],
|
|
movies: ["Movie"],
|
|
songs: ["Audio"],
|
|
albums: ["MusicAlbum"],
|
|
artists: ["MusicArtist"],
|
|
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<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.
|
|
*
|
|
* The stored array is a *hint*, not a contract: ids that no longer exist are
|
|
* dropped, and groups it never mentions (a user upgrading from a build with
|
|
* fewer groups) are appended in default order rather than lost.
|
|
*
|
|
* TRACES: UR-050 | DR-066
|
|
*/
|
|
export function normalizeGroupOrder(stored: unknown): SearchGroupId[] {
|
|
const known = new Set<string>(DEFAULT_GROUP_ORDER);
|
|
const seen = new Set<SearchGroupId>();
|
|
const order: SearchGroupId[] = [];
|
|
|
|
if (Array.isArray(stored)) {
|
|
for (const id of stored) {
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const id of DEFAULT_GROUP_ORDER) {
|
|
if (!seen.has(id)) order.push(id);
|
|
}
|
|
|
|
return order;
|
|
}
|
|
|
|
/** Groups visible under a scope, in the user's configured order. */
|
|
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
|
|
);
|
|
}
|
|
|
|
export interface SearchGroup<T> {
|
|
id: SearchGroupId;
|
|
label: string;
|
|
items: T[];
|
|
}
|
|
|
|
/**
|
|
* Compose scope, saved order and the results into the sections to render:
|
|
* drop out-of-scope groups, sort by the saved order, omit empty groups.
|
|
*
|
|
* TRACES: UR-050 | DR-067
|
|
*/
|
|
export function composeSearchGroups<T extends { type?: string | null }>(
|
|
results: readonly T[],
|
|
scope: SearchScope,
|
|
order: readonly SearchGroupId[] = DEFAULT_GROUP_ORDER
|
|
): SearchGroup<T>[] {
|
|
return groupsForScope(scope, order)
|
|
.map((id) => {
|
|
const types = GROUP_ITEM_TYPES[id];
|
|
return {
|
|
id,
|
|
label: GROUP_LABELS[id],
|
|
items: results.filter((item) => item.type != null && types.includes(item.type)),
|
|
};
|
|
})
|
|
.filter((group) => group.items.length > 0);
|
|
}
|
|
|
|
/** Move a group one slot up (-1) or down (+1); out-of-range moves are no-ops. */
|
|
export function moveGroup(
|
|
order: readonly SearchGroupId[],
|
|
id: SearchGroupId,
|
|
delta: number
|
|
): SearchGroupId[] {
|
|
const next = [...order];
|
|
const from = next.indexOf(id);
|
|
if (from === -1) return next;
|
|
const to = from + delta;
|
|
if (to < 0 || to >= next.length) return next;
|
|
next.splice(to, 0, ...next.splice(from, 1));
|
|
return next;
|
|
}
|
|
|
|
/** Move a group from one index to another (drag-and-drop drop handler). */
|
|
export function reorderGroups(
|
|
order: readonly SearchGroupId[],
|
|
from: number,
|
|
to: number
|
|
): SearchGroupId[] {
|
|
const next = [...order];
|
|
if (from < 0 || from >= next.length || to < 0 || to >= next.length || from === to) return next;
|
|
next.splice(to, 0, ...next.splice(from, 1));
|
|
return next;
|
|
}
|