🏗️ Build and Test JellyTau / Run Tests (push) Successful in 20m34s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 6m6s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Successful in 20m26s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 10m3s
Build & Release / Build Linux (push) Successful in 37m59s
Build & Release / Build Windows (push) Successful in 23m0s
Build & Release / Build Android (push) Successful in 40m26s
Build & Release / Create Release (push) Successful in 1m20s
Bundles this session's work plus the concurrent search/offline/player changes.
Every gate passes on the combined tree: 885 frontend tests, 610 Rust tests,
clippy clean, boundary clean, trace coverage 86%.
Offline video playback — four separate defects, each of which alone stopped it:
DR-133 A completed download's file_path is already absolute (the worker
rewrites it on completion), but the player rooted it a second time and
handed the webview /data/user/0/app//data/user/0/app/videos/x.mp4.
DR-134 The asset protocol was never enabled: no protocol-asset feature and no
assetProtocol config, so convertFileSrc produced URLs nothing answered.
Also silently defeated the cached-thumbnail path, which fails soft to
the server copy and hid it whenever the server was reachable.
DR-137 Tauri's asset protocol answers a range-less request by reading the
whole file into memory, and only advertises Accept-Ranges from inside
its range branch, so the first request never learns ranges exist.
Chromium gave up with PIPELINE_ERROR_READ after ~31s. Local media is
now served by a loopback HTTP server: bounded 4 MiB chunks streamed
from the file handle, every response length-delimited, and a range-less
request answered with one chunk rather than the file. Confined by a
per-session token and to the app data directory, because loopback is
shared between apps on Android.
DR-138 Release builds set usesCleartextTraffic=false, so Android rejected the
request to that server before any I/O. A network-security-config
exempts 127.0.0.1 only; a remote server must still be HTTPS.
Downloads:
DR-135 download_item never records media_type and the reconnect resolver read
that NULL as 'audio', so a movie queued from a media card had its URL
resolved by get_audio_stream_url and completed as an audio-only
transcode. The item's own type now decides.
DR-136 Rows already downloaded that way are requeued on reconnect, since
prevention alone leaves them reading "downloaded" and still unplayable.
Known limitation: a download taken at `original` quality is a byte copy of the
source, so it can be any container. One such file is an AVI holding XVID, which
the webview cannot play in any case — the media server serves it correctly and
Chromium refuses it. That needs either a transcoded download preset or the
native ExoPlayer surface work, and is not addressed here.
Also fixes two ID collisions between concurrent work: DR-143 defined twice
(search vs offline gate) and UT-131 defined twice (Episode Focus hero vs channel
cap). The search requirement is now DR-147 and the channel-cap test UT-141, with
their code references and matrix rows updated.
335 lines
11 KiB
TypeScript
335 lines
11 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")}`;
|
|
}
|
|
|
|
/** Normalise a pathname for comparison: drop query, hash and trailing slashes. */
|
|
function normalizePath(pathname: string): string {
|
|
return pathname.split(/[?#]/)[0].replace(/\/+$/, "") || "/";
|
|
}
|
|
|
|
/** Whether `pathname` is the search surface itself. */
|
|
export function isSearchRoute(pathname: string): boolean {
|
|
return normalizePath(pathname) === "/search";
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
return !isSearchRoute(pathname);
|
|
}
|
|
|
|
/** Read a `?scope=` value, falling back when it is absent or unrecognised. */
|
|
export function parseSearchScope(
|
|
raw: string | null | undefined,
|
|
fallback: SearchScope = "all"
|
|
): SearchScope {
|
|
return SEARCH_SCOPES.includes(raw as SearchScope) ? (raw as SearchScope) : fallback;
|
|
}
|
|
|
|
/** The query + scope a `/search` URL asks the page to show. */
|
|
export interface SearchSeed {
|
|
query: string;
|
|
scope: SearchScope;
|
|
}
|
|
|
|
/**
|
|
* What a `/search` URL should seed the page with, or `null` if it asks for
|
|
* nothing new.
|
|
*
|
|
* The URL is *consumed once per value*, not continuously reconciled against the
|
|
* live input. `applied` is the seed the caller last took from the URL: while it
|
|
* still matches, the user's own typing and chip picks govern, and only a real
|
|
* navigation (the header search sending a new query) re-seeds the page.
|
|
*
|
|
* Reconciling instead of consuming was the bug this replaced — the old effect
|
|
* compared the URL against `library.searchQuery`, so every keystroke's search
|
|
* re-ran it and snapped the input back to the query the header had sent.
|
|
*
|
|
* TRACES: UR-049 | DR-063, DR-064
|
|
*/
|
|
export function seedFromSearchUrl(
|
|
params: URLSearchParams,
|
|
applied: SearchSeed | null
|
|
): SearchSeed | null {
|
|
const seed: SearchSeed = {
|
|
query: params.get("q") ?? "",
|
|
scope: parseSearchScope(params.get("scope")),
|
|
};
|
|
if (applied && applied.query === seed.query && applied.scope === seed.scope) return null;
|
|
return seed;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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;
|
|
}
|