commit 1964b15ee092b93d827abb36bf41be9731a80743
parent 8c10cd05de71775cd58a2986975c6d790546190d
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 20 May 2026 18:51:47 -0400
Layered search
Diffstat:
14 files changed, 2979 insertions(+), 743 deletions(-)
diff --git a/common/components/LayerSwatch.tsx b/common/components/LayerSwatch.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+// Per-leaf colour swatch. Each leaf in the composite-search tree gets a
+// deterministic hue derived from its `id`, so the same leaf renders the
+// same colour in its editor card AND on every result hit it contributes.
+// Hue space is mod 360 with fixed saturation/lightness for a consistent
+// palette in both light and dark themes.
+
+function hashStr(s: string): number {
+ let h = 0;
+ for (let i = 0; i < s.length; i++) {
+ h = (h * 31 + s.charCodeAt(i)) | 0;
+ }
+ return h;
+}
+
+export function swatchHue(id: string): number {
+ return Math.abs(hashStr(id)) % 360;
+}
+
+export function swatchBgStyle(id: string): React.CSSProperties {
+ const h = swatchHue(id);
+ return {
+ backgroundColor: `hsl(${h} 70% 55%)`,
+ };
+}
+
+export function swatchSoftBgStyle(id: string): React.CSSProperties {
+ const h = swatchHue(id);
+ return {
+ backgroundColor: `hsl(${h} 70% 55% / 0.14)`,
+ borderColor: `hsl(${h} 70% 55% / 0.45)`,
+ };
+}
+
+export function LayerSwatch({
+ leafId,
+ size = "sm",
+ className = "",
+}: {
+ leafId: string;
+ size?: "xs" | "sm";
+ className?: string;
+}) {
+ const dim = size === "xs" ? "h-2 w-2" : "h-2.5 w-2.5";
+ return (
+ <span
+ className={`inline-block rounded-full ${dim} shrink-0 ${className}`}
+ style={swatchBgStyle(leafId)}
+ aria-hidden="true"
+ />
+ );
+}
diff --git a/common/components/QueryBuilder.tsx b/common/components/QueryBuilder.tsx
@@ -0,0 +1,129 @@
+"use client";
+
+// Top-level composite-search UI. Renders the root group of a QueryNode tree
+// and wires up immutable-mutation callbacks (replaceNode / removeNode /
+// insertChild / wrapInGroup from `searchQuery.ts`). The parent owns the
+// committed root; this component is fully controlled.
+//
+// When the tree is "trivially simple" — root AND group with exactly one
+// non-negated leaf — the builder renders in compact mode (one input row +
+// a small "Add layer" link), preserving the look of the original search bar.
+
+import {
+ insertChild,
+ isLeaf,
+ newGroup,
+ newLeaf,
+ removeNode,
+ replaceNode,
+ wrapInGroup,
+ type GroupNode,
+ type LeafNode,
+ type QueryNode,
+} from "../lib/searchQuery";
+import type { GroupState, LeafState } from "../lib/searchEval";
+import QueryGroupView from "./QueryGroupView";
+import QueryLeafView from "./QueryLeafView";
+
+type Props = {
+ root: GroupNode;
+ onChange: (next: GroupNode) => void;
+ leafStates: ReadonlyMap<string, LeafState>;
+ groupStates: ReadonlyMap<string, GroupState>;
+};
+
+export default function QueryBuilder({
+ root,
+ onChange,
+ leafStates,
+ groupStates,
+}: Props) {
+ const onChangeNode = (id: string, next: QueryNode) => {
+ if (id === root.id) {
+ if (next.kind === "group") onChange(next);
+ return;
+ }
+ onChange(replaceNode(root, id, next));
+ };
+ const onRemoveNode = (id: string) => onChange(removeNode(root, id));
+ const onAddChild = (groupId: string, child: QueryNode) =>
+ onChange(insertChild(root, groupId, child));
+ const onWrapLeaf = (leafId: string) =>
+ onChange(wrapInGroup(root, leafId, "AND"));
+
+ // Compact mode: root AND group with one non-negated leaf, no negation
+ // applied to the root either. Anything else falls back to the full
+ // builder rendering.
+ const onlyChild = root.children.length === 1 ? root.children[0] : null;
+ const isCompactCandidate =
+ !root.negate &&
+ root.op === "AND" &&
+ onlyChild !== null &&
+ isLeaf(onlyChild) &&
+ !onlyChild.negate;
+
+ if (isCompactCandidate && onlyChild && isLeaf(onlyChild)) {
+ const leaf = onlyChild as LeafNode;
+ return (
+ <div className="flex flex-col gap-2" data-testid="query-builder">
+ <QueryLeafView
+ leaf={leaf}
+ state={leafStates.get(leaf.id)}
+ compact
+ canDelete={false}
+ canNegate={false}
+ onChange={(next) => onChangeNode(leaf.id, next)}
+ onDelete={() => {
+ /* not reachable: canDelete=false in compact mode */
+ }}
+ />
+ <div className="flex items-center gap-2 text-xs">
+ <button
+ type="button"
+ onClick={() =>
+ onAddChild(
+ root.id,
+ newLeaf({ scope: "transcripts", contributeHits: true }),
+ )
+ }
+ data-testid="compact-add-layer"
+ className="underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
+ >
+ + Add layer
+ </button>
+ <button
+ type="button"
+ onClick={() =>
+ onAddChild(
+ root.id,
+ newGroup({
+ op: "OR",
+ children: [newLeaf({ scope: "transcripts" })],
+ }),
+ )
+ }
+ className="underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
+ >
+ + Add group
+ </button>
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div data-testid="query-builder">
+ <QueryGroupView
+ group={root}
+ isRoot
+ depth={0}
+ leafStates={leafStates}
+ groupStates={groupStates}
+ onChangeNode={onChangeNode}
+ onRemoveNode={onRemoveNode}
+ onAddChild={onAddChild}
+ onWrapLeaf={onWrapLeaf}
+ />
+ </div>
+ );
+}
diff --git a/common/components/QueryGroupView.tsx b/common/components/QueryGroupView.tsx
@@ -0,0 +1,171 @@
+"use client";
+
+// Group container for the composite search builder. Renders its op
+// (AND / OR), optional NOT flag, live count, and children (leaves or
+// nested groups). When the root group is in compact mode (single leaf,
+// no negation), the parent renders just the leaf directly without this
+// wrapper to keep the simple-search case visually minimal.
+
+import {
+ isGroup,
+ isLeaf,
+ newGroup,
+ newLeaf,
+ type GroupNode,
+ type QueryNode,
+} from "../lib/searchQuery";
+import type { GroupState, LeafState } from "../lib/searchEval";
+import QueryLeafView from "./QueryLeafView";
+
+type Props = {
+ group: GroupNode;
+ isRoot: boolean;
+ depth: number;
+ leafStates: ReadonlyMap<string, LeafState>;
+ groupStates: ReadonlyMap<string, GroupState>;
+ onChangeNode: (id: string, next: QueryNode) => void;
+ onRemoveNode: (id: string) => void;
+ onAddChild: (groupId: string, child: QueryNode) => void;
+ onWrapLeaf: (leafId: string) => void;
+};
+
+export default function QueryGroupView({
+ group,
+ isRoot,
+ depth,
+ leafStates,
+ groupStates,
+ onChangeNode,
+ onRemoveNode,
+ onAddChild,
+ onWrapLeaf,
+}: Props) {
+ const state = groupStates.get(group.id);
+ const onlyLeafCount = group.children.filter(isLeaf).length;
+
+ const indent = depth === 0 ? "" : "pl-4 border-l-2 border-zinc-200 dark:border-zinc-800";
+
+ return (
+ <div
+ className={`flex flex-col gap-2 ${indent}`}
+ data-group-id={group.id}
+ data-group-op={group.op}
+ >
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
+ <select
+ value={group.op}
+ onChange={(e) =>
+ onChangeNode(group.id, { ...group, op: e.target.value as "AND" | "OR" })
+ }
+ data-testid={`group-op-${group.id}`}
+ className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-1.5 py-1 text-xs font-medium"
+ >
+ <option value="AND">ALL of (AND)</option>
+ <option value="OR">ANY of (OR)</option>
+ </select>
+ {!isRoot && (
+ <label className="flex items-center gap-1 select-none">
+ <input
+ type="checkbox"
+ checked={group.negate}
+ onChange={(e) =>
+ onChangeNode(group.id, { ...group, negate: e.target.checked })
+ }
+ className="accent-red-500"
+ data-testid={`group-negate-${group.id}`}
+ />
+ NOT
+ </label>
+ )}
+ {state && (
+ <span
+ className="text-zinc-500 dark:text-zinc-400"
+ data-testid={`group-count-${group.id}`}
+ >
+ → {state.slugCount} video{state.slugCount === 1 ? "" : "s"}
+ </span>
+ )}
+ {!isRoot && (
+ <button
+ type="button"
+ onClick={() => onRemoveNode(group.id)}
+ aria-label="Remove group"
+ data-testid={`group-delete-${group.id}`}
+ className="ml-auto text-xs rounded border border-zinc-300 dark:border-zinc-700 w-6 h-6 inline-flex items-center justify-center hover:bg-zinc-100 dark:hover:bg-zinc-800"
+ >
+ ×
+ </button>
+ )}
+ </div>
+
+ <div className="flex flex-col gap-2">
+ {group.children.map((child) => {
+ if (isLeaf(child)) {
+ return (
+ <QueryLeafView
+ key={child.id}
+ leaf={child}
+ state={leafStates.get(child.id)}
+ compact={false}
+ canDelete={group.children.length > 1 || !isRoot}
+ onChange={(next) => onChangeNode(child.id, next)}
+ onDelete={() => onRemoveNode(child.id)}
+ onWrapInGroup={() => onWrapLeaf(child.id)}
+ isOnlyLeaf={onlyLeafCount === 1}
+ />
+ );
+ }
+ if (isGroup(child)) {
+ return (
+ <QueryGroupView
+ key={child.id}
+ group={child}
+ isRoot={false}
+ depth={depth + 1}
+ leafStates={leafStates}
+ groupStates={groupStates}
+ onChangeNode={onChangeNode}
+ onRemoveNode={onRemoveNode}
+ onAddChild={onAddChild}
+ onWrapLeaf={onWrapLeaf}
+ />
+ );
+ }
+ return null;
+ })}
+ </div>
+
+ <div className="flex flex-wrap items-center gap-2 text-xs">
+ <button
+ type="button"
+ onClick={() =>
+ onAddChild(
+ group.id,
+ newLeaf({ scope: "transcripts", contributeHits: true }),
+ )
+ }
+ data-testid={`group-add-leaf-${group.id}`}
+ className="rounded border border-zinc-300 dark:border-zinc-700 px-2 py-1 hover:bg-zinc-100 dark:hover:bg-zinc-800"
+ >
+ + Add layer
+ </button>
+ <button
+ type="button"
+ onClick={() =>
+ onAddChild(
+ group.id,
+ newGroup({
+ op: "OR",
+ children: [newLeaf({ scope: "transcripts" })],
+ }),
+ )
+ }
+ data-testid={`group-add-group-${group.id}`}
+ className="rounded border border-zinc-300 dark:border-zinc-700 px-2 py-1 hover:bg-zinc-100 dark:hover:bg-zinc-800"
+ >
+ + Add group
+ </button>
+ </div>
+ </div>
+ );
+}
diff --git a/common/components/QueryLeafView.tsx b/common/components/QueryLeafView.tsx
@@ -0,0 +1,231 @@
+"use client";
+
+// Single-leaf editor card for the composite search builder.
+//
+// Renders one row when the parent says "compact mode" (the simple-search
+// case: root is one AND group with one leaf, no negation), otherwise a
+// boxed card with all controls visible. The card shows its layer swatch,
+// scope dropdown, query input, regex / show-hits / NOT toggles, a live
+// "→ N videos" count, a cached badge when applicable, and a delete button.
+
+import type { LeafNode, LayerScope } from "../lib/searchQuery";
+import type { LeafState } from "../lib/searchEval";
+import { LayerSwatch, swatchSoftBgStyle } from "./LayerSwatch";
+
+type Props = {
+ leaf: LeafNode;
+ state: LeafState | undefined;
+ compact?: boolean;
+ canDelete: boolean;
+ canNegate?: boolean;
+ isOnlyLeaf?: boolean;
+ onChange: (next: LeafNode) => void;
+ onDelete: () => void;
+ onWrapInGroup?: () => void;
+};
+
+const SCOPE_LABELS: Record<LayerScope, string> = {
+ transcripts: "Transcripts",
+ chat: "Live chat",
+ metadata: "Title / channel",
+};
+
+const SCOPE_COST: Record<LayerScope, string> = {
+ transcripts: "slow",
+ chat: "medium",
+ metadata: "fast",
+};
+
+export default function QueryLeafView({
+ leaf,
+ state,
+ compact = false,
+ canDelete,
+ canNegate = true,
+ onChange,
+ onDelete,
+ onWrapInGroup,
+}: Props) {
+ const update = (patch: Partial<LeafNode>) =>
+ onChange({ ...leaf, ...patch });
+
+ const regexInvalid =
+ leaf.useRegex && leaf.query.trim() !== "" && !isValidRegex(leaf.query);
+
+ const placeholder =
+ leaf.scope === "metadata"
+ ? leaf.useRegex
+ ? "Title/channel regex..."
+ : "Match against title or channel..."
+ : leaf.scope === "chat"
+ ? leaf.useRegex
+ ? "Live chat regex..."
+ : "Search live chat..."
+ : leaf.useRegex
+ ? "Regex pattern (case-insensitive)..."
+ : "Search transcripts...";
+
+ const queryInput = (
+ <input
+ type="search"
+ value={leaf.query}
+ onChange={(e) => update({ query: e.target.value })}
+ placeholder={placeholder}
+ data-testid={`leaf-query-${leaf.id}`}
+ className={`flex-1 min-w-0 rounded-lg border ${
+ regexInvalid
+ ? "border-red-500"
+ : "border-zinc-300 dark:border-zinc-700"
+ } bg-white dark:bg-zinc-900 px-3 py-2 text-base outline-none focus:ring-2 focus:ring-blue-500 ${
+ leaf.useRegex ? "font-mono" : ""
+ }`}
+ />
+ );
+
+ const scopeSelect = (
+ <label className="flex items-center gap-1.5 text-xs text-zinc-600 dark:text-zinc-400">
+ <select
+ value={leaf.scope}
+ onChange={(e) => update({ scope: e.target.value as LayerScope })}
+ data-testid={`leaf-scope-${leaf.id}`}
+ className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-1.5 py-1 text-xs"
+ >
+ {(["transcripts", "chat", "metadata"] as const).map((s) => (
+ <option key={s} value={s}>
+ {SCOPE_LABELS[s]}
+ </option>
+ ))}
+ </select>
+ <span className="text-[10px] uppercase tracking-wide text-zinc-400">
+ {SCOPE_COST[leaf.scope]}
+ </span>
+ </label>
+ );
+
+ const regexToggle = (
+ <label className="flex items-center gap-1 text-xs select-none">
+ <input
+ type="checkbox"
+ checked={leaf.useRegex}
+ onChange={(e) => update({ useRegex: e.target.checked })}
+ className="accent-blue-600"
+ data-testid={`leaf-regex-${leaf.id}`}
+ />
+ Regex
+ </label>
+ );
+
+ const countBadge = state ? (
+ <span
+ className="ml-auto text-xs text-zinc-500 dark:text-zinc-400 shrink-0"
+ data-testid={`leaf-count-${leaf.id}`}
+ >
+ → {state.slugCount} video{state.slugCount === 1 ? "" : "s"}
+ {state.active ? "…" : ""}
+ </span>
+ ) : null;
+
+ const cachedBadge =
+ state?.cached && !state.active ? (
+ <span
+ className="ml-1 text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300"
+ data-cache-hit={leaf.id}
+ title="Served from layer cache"
+ >
+ cached
+ </span>
+ ) : null;
+
+ if (compact) {
+ return (
+ <div className="flex flex-col gap-1.5">
+ <div className="flex items-center gap-2">
+ <LayerSwatch leafId={leaf.id} />
+ {queryInput}
+ </div>
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-zinc-600 dark:text-zinc-400">
+ {scopeSelect}
+ {regexToggle}
+ {countBadge}
+ {cachedBadge}
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div
+ className="flex flex-col gap-2 rounded-lg border bg-white/60 dark:bg-zinc-900/40 px-3 py-2"
+ style={swatchSoftBgStyle(leaf.id)}
+ data-leaf-id={leaf.id}
+ >
+ <div className="flex items-center gap-2">
+ <LayerSwatch leafId={leaf.id} />
+ {queryInput}
+ {countBadge}
+ </div>
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
+ {scopeSelect}
+ {regexToggle}
+ <label className="flex items-center gap-1 select-none">
+ <input
+ type="checkbox"
+ checked={leaf.contributeHits}
+ onChange={(e) => update({ contributeHits: e.target.checked })}
+ className="accent-blue-600"
+ data-testid={`leaf-hits-${leaf.id}`}
+ />
+ Show hits in results
+ </label>
+ {canNegate && (
+ <label className="flex items-center gap-1 select-none">
+ <input
+ type="checkbox"
+ checked={leaf.negate}
+ onChange={(e) => update({ negate: e.target.checked })}
+ className="accent-red-500"
+ data-testid={`leaf-negate-${leaf.id}`}
+ />
+ NOT
+ </label>
+ )}
+ {cachedBadge}
+ <div className="ml-auto flex items-center gap-1">
+ {onWrapInGroup && (
+ <button
+ type="button"
+ onClick={onWrapInGroup}
+ className="text-xs underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
+ title="Wrap this layer in a sub-group so you can add OR siblings"
+ data-testid={`leaf-wrap-${leaf.id}`}
+ >
+ wrap
+ </button>
+ )}
+ <button
+ type="button"
+ onClick={onDelete}
+ disabled={!canDelete}
+ aria-label="Remove layer"
+ data-testid={`leaf-delete-${leaf.id}`}
+ className="text-xs rounded border border-zinc-300 dark:border-zinc-700 w-6 h-6 inline-flex items-center justify-center hover:bg-zinc-100 dark:hover:bg-zinc-800 disabled:opacity-30 disabled:cursor-not-allowed"
+ >
+ ×
+ </button>
+ </div>
+ </div>
+ {regexInvalid && (
+ <p className="text-xs text-red-600 font-mono">Invalid regex</p>
+ )}
+ </div>
+ );
+}
+
+function isValidRegex(s: string): boolean {
+ try {
+ new RegExp(s);
+ return true;
+ } catch {
+ return false;
+ }
+}
diff --git a/common/components/TranscriptSearch.tsx b/common/components/TranscriptSearch.tsx
@@ -4,14 +4,24 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { usePlayer } from "./PlayerProvider";
import { useSummaries } from "./summariesCache";
import { useChannelSubsManifests, useSubsManifest } from "./subsCache";
+import type { LayerHit } from "./searchPipeline";
import {
- createSearchPipeline,
- createSubsSearchPipeline,
- filterSlugs,
- type Hit,
- type PipelineController,
- type SubsHit,
-} from "./searchPipeline";
+ runQueryTree,
+ type GroupState,
+ type LeafState,
+ type TreeController,
+ type TreeProgress,
+} from "../lib/searchEval";
+import {
+ canonicalHash,
+ emptyRoot,
+ forEachLeaf,
+ isNodeActive,
+ parseRoot,
+ rootFromLegacy,
+ stringifyRoot,
+ type GroupNode,
+} from "../lib/searchQuery";
import { useUrlParams, writeUrlParams } from "./urlState";
import {
buildShareSearchParams,
@@ -35,6 +45,8 @@ import {
saveAdvanced,
} from "./exportAdvancedStorage";
import { AgeRestrictedBadge, LivestreamBadge } from "./badges";
+import QueryBuilder from "./QueryBuilder";
+import { LayerSwatch } from "./LayerSwatch";
import type { DisplaySummary } from "../lib/transcripts";
import {
DEFAULT_GROUP_FALLBACK_ID,
@@ -46,26 +58,14 @@ import {
type Summary = DisplaySummary;
-type HitGroup = {
+type ResultGroup = {
slug: string;
title: string;
channel: string;
date: string;
isLivestream: boolean;
ageRestricted: boolean;
- titleMatch?: string;
- hits: Hit[];
-};
-
-type SubsHitGroup = {
- slug: string;
- title: string;
- channel: string;
- date: string;
- isLivestream: boolean;
- ageRestricted: boolean;
- titleMatch?: string;
- hits: SubsHit[];
+ hits: LayerHit[];
};
const DEFAULT_MAX_HITS = 500;
@@ -73,9 +73,8 @@ const DEFAULT_FETCH_CONCURRENCY = 6;
const DEFAULT_FLUSH_INTERVAL_MS = 120;
const DEFAULT_GROUPS_PAGE_SIZE = 50;
-// URL params the export UI considers "filter state". Used by the hydration
-// path to detect whether the URL is authoritative — if any of these are set,
-// the URL wins over localStorage.
+// Legacy URL keys still parsed for hydration (old share-links), but stripped
+// on the first commit so the modern `qt=` is canonical going forward.
const FILTER_URL_KEYS = [
"ch",
"nov",
@@ -95,8 +94,7 @@ function urlHasAnyFilterParam(): boolean {
}
// Resolve a stored snapshot's per-channel deltas (plus group defaults) into
-// the concrete set of EXCLUDED channel names — i.e. the format already used
-// by the URL `ch` param and the existing draftExcludedChannels Set.
+// the concrete set of EXCLUDED channel names.
function snapshotToExcluded(
snapshot: FilterSnapshot | null,
channelOptions: ReadonlyArray<string>,
@@ -106,7 +104,7 @@ function snapshotToExcluded(
const excluded = new Set(snapshot?.channels.excluded ?? []);
const out = new Set<string>();
for (const name of channelOptions) {
- if (included.has(name)) continue; // user override: on
+ if (included.has(name)) continue;
if (excluded.has(name)) {
out.add(name);
continue;
@@ -116,9 +114,6 @@ function snapshotToExcluded(
return out;
}
-// Inverse of snapshotToExcluded: compute the explicit user-delta (included+
-// excluded) given the resolved excluded set and the group defaults. Anything
-// matching the default is omitted so newly-added channels can inherit.
function buildSnapshotDeltas(
excludedSet: ReadonlySet<string>,
channelOptions: ReadonlyArray<string>,
@@ -136,18 +131,28 @@ function buildSnapshotDeltas(
return { included: included.sort(), excluded: excluded.sort() };
}
+function anyLeafHasScope(
+ root: GroupNode,
+ scope: "transcripts" | "chat" | "metadata",
+): boolean {
+ let found = false;
+ forEachLeaf(root, (leaf) => {
+ if (leaf.scope === scope && leaf.query.trim()) found = true;
+ });
+ return found;
+}
+
export default function TranscriptSearch() {
const {
- q: query,
- re: useRegex,
+ q: legacyQ,
+ re: legacyRe,
v: activeVideo,
t: activeTime,
- mode,
+ mode: legacyMode,
} = useUrlParams();
+
// Committed filter state lives in React (not the URL) since filters are
- // no longer auto-tracked — hydration seeds this from a share-link URL,
- // a legacy URL, or localStorage, and `commitSearch`/profile-load update
- // it directly.
+ // no longer auto-tracked.
const [committedExcludedChannels, setCommittedExcludedChannels] = useState<
Set<string>
>(() => new Set());
@@ -157,23 +162,23 @@ export default function TranscriptSearch() {
const [committedNar, setCommittedNar] = useState(false);
const [committedNav, setCommittedNav] = useState(false);
const [committedNd, setCommittedNd] = useState(false);
- const [committedExcludedTracks, setCommittedExcludedTracks] = useState<
- Set<string>
- >(() => new Set());
- // Content-based keys so Set identity doesn't churn unnecessarily.
const committedChannelsKey = useMemo(
() => Array.from(committedExcludedChannels).sort().join(" "),
[committedExcludedChannels],
);
const committedChannels = committedExcludedChannels;
- const committedTracksKey = useMemo(
- () => Array.from(committedExcludedTracks).sort().join(" "),
- [committedExcludedTracks],
+
+ // Query tree: draft (what the user is editing) vs. committed (what is
+ // currently being searched). `committedHash` is a fingerprint so React
+ // effects can dep on it without recomputing on identity churn.
+ const [draftRoot, setDraftRoot] = useState<GroupNode>(emptyRoot);
+ const [committedRoot, setCommittedRoot] = useState<GroupNode>(emptyRoot);
+ const draftHash = useMemo(() => canonicalHash(draftRoot), [draftRoot]);
+ const committedHash = useMemo(
+ () => canonicalHash(committedRoot),
+ [committedRoot],
);
- const [input, setInput] = useState(query);
- useEffect(() => {
- setInput(query);
- }, [query]);
+ const queryDirty = draftHash !== committedHash;
const [draftExcludedChannels, setDraftExcludedChannels] = useState<
Set<string>
@@ -184,15 +189,7 @@ export default function TranscriptSearch() {
const [draftNar, setDraftNar] = useState(false);
const [draftNav, setDraftNav] = useState(false);
const [draftNd, setDraftNd] = useState(false);
- const [draftRegex, setDraftRegex] = useState(useRegex);
- const [draftExcludedTracks, setDraftExcludedTracks] = useState<Set<string>>(
- () => new Set(),
- );
- useEffect(() => setDraftRegex(useRegex), [useRegex]);
- // Persisted filter state, profiles, and UI collapse — see exportFilterStorage.
- // Hydration is gated until the manifest is loaded so we know which channels
- // exist when applying stored deltas.
const [hydrated, setHydrated] = useState(false);
const [profiles, setProfiles] = useState<Record<string, FilterSnapshot>>({});
const [activeProfileName, setActiveProfileName] = useState<string | null>(
@@ -202,39 +199,58 @@ export default function TranscriptSearch() {
() => new Set(),
);
- const [searchExecuted, setSearchExecuted] = useState(() => {
+ // Tracks whether the user has committed at least once this session. Used
+ // by the placeholder copy below — until the user has searched, we show a
+ // "tip" hint rather than the "no matching videos" empty state.
+ const [, setSearchExecuted] = useState<boolean>(() => {
if (typeof window === "undefined") return true;
const params = new URLSearchParams(window.location.search);
- return !(params.has("v") && (params.get("q") ?? "") !== "");
+ return !(
+ params.has("v") &&
+ !params.has("qt") &&
+ (params.get("q") ?? "") === ""
+ );
});
+ // Defer rendering of the QueryBuilder to the client. The builder's leaf
+ // IDs come from a module-scoped counter that's necessarily out of sync
+ // between the long-running server process and a fresh client module load,
+ // which would otherwise produce a hydration warning on every page load.
+ const [mounted, setMounted] = useState(false);
+ useEffect(() => setMounted(true), []);
+
const { manifest, summaries, loadedPages, pageCount, summariesReady } =
useSummaries();
const transcripts: Summary[] | null = summariesReady ? summaries : null;
+ // We need subs manifests whenever any leaf has scope=chat OR a chat leaf
+ // could be added (we eagerly load when the tab existed in v1). For v2 we
+ // only load subs manifests when a chat leaf is actually in the tree.
const subsManifestQuery = useSubsManifest();
const subsManifest = subsManifestQuery.data ?? null;
- // Once the subs manifest has loaded, we know whether any channel actually
- // has subs. If none do, suppress the subs tab AND force-collapse subs mode
- // even when ?m=subs is in the URL — there's nothing to render.
- // Tab is gated on live-chat content specifically — the export UI only
- // surfaces live chat in subs mode, so a manifest with only alt-language
- // tracks should hide the tab entirely.
const liveChatTotalCount = subsManifest?.liveChatTotalCount ?? 0;
const hasSubs = subsManifest !== null && liveChatTotalCount > 0;
- const isSubsMode = mode === "subs" && hasSubs;
+ const draftHasChatLeaf = useMemo(
+ () => anyLeafHasScope(draftRoot, "chat"),
+ [draftRoot],
+ );
+ const committedHasChatLeaf = useMemo(
+ () => anyLeafHasScope(committedRoot, "chat"),
+ [committedRoot],
+ );
+ const needsChatManifests = draftHasChatLeaf || committedHasChatLeaf;
const subsChannelSlugs = useMemo(
() => (subsManifest ? subsManifest.channels.map((c) => c.slug) : []),
[subsManifest],
);
const channelSubsQueries = useChannelSubsManifests(
- isSubsMode ? subsChannelSlugs : [],
+ needsChatManifests ? subsChannelSlugs : [],
);
- // Aggregate set of all sub-having slugs (channelSlug/videoId) across loaded
- // channel manifests. Driven only when in subs mode.
- const allSubSlugs = useMemo<Set<string>>(() => {
+ // Set of (channelSlug/videoId) slugs that have live_chat content,
+ // resolved across all per-channel subs manifests.
+ const chatScopeSlugs = useMemo<Set<string>>(() => {
const set = new Set<string>();
- if (!isSubsMode) return set;
+ if (!needsChatManifests) return set;
for (let i = 0; i < channelSubsQueries.length; i++) {
const q = channelSubsQueries[i];
const channelSlug = subsChannelSlugs[i];
@@ -247,41 +263,17 @@ export default function TranscriptSearch() {
return set;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
- isSubsMode,
+ needsChatManifests,
subsChannelSlugs,
channelSubsQueries.map((q) => (q.data ? 1 : 0)).join(""),
]);
const subsManifestReady =
- isSubsMode &&
- subsManifest !== null &&
- channelSubsQueries.length > 0 &&
- channelSubsQueries.every((q) => q.data);
- const availableTracks = useMemo<string[]>(() => {
- if (!subsManifest) return [];
- const set = new Set<string>();
- for (const c of subsManifest.channels) {
- for (const t of c.tracks) set.add(t);
- }
- return Array.from(set).sort();
- }, [subsManifest]);
- // The "Live chat" tab is scoped to the live_chat track only — any other
- // tracks present in the manifest (alt-language captions, etc.) are forced
- // into the excluded set so the pipeline ignores them. Replaces the
- // user-controlled draftExcludedTracks for the subs-mode call site.
- const liveChatOnlyExcludedTracks = useMemo<Set<string>>(
- () => new Set(availableTracks.filter((t) => t !== "live_chat")),
- [availableTracks],
- );
+ !needsChatManifests ||
+ (subsManifest !== null &&
+ channelSubsQueries.length > 0 &&
+ channelSubsQueries.every((q) => q.data));
- const [hitsBySlug, setHitsBySlug] = useState<Record<string, Hit[]>>({});
- const [subsHitsBySlug, setSubsHitsBySlug] = useState<
- Record<string, SubsHit[]>
- >({});
- const [totalHits, setTotalHits] = useState(0);
- const [processed, setProcessed] = useState(0);
- const [totalToProcess, setTotalToProcess] = useState(0);
- const [pipelineDone, setPipelineDone] = useState(false);
- const [capped, setCapped] = useState(false);
+ const [treeProgress, setTreeProgress] = useState<TreeProgress | null>(null);
// Advanced options — configurable from the UI, pre-filled with defaults.
const [hitBatchValue, setHitBatchValue] = useState(DEFAULT_MAX_HITS);
@@ -301,8 +293,6 @@ export default function TranscriptSearch() {
? Infinity
: groupsBatchValue;
- // Hydrate the advanced-options panel from localStorage once on mount.
- // Independent of filter persistence — see exportAdvancedStorage.ts.
const [advancedHydrated, setAdvancedHydrated] = useState(false);
useEffect(() => {
if (advancedHydrated) return;
@@ -318,8 +308,6 @@ export default function TranscriptSearch() {
setAdvancedHydrated(true);
}, [advancedHydrated]);
- // Persist any change to advanced options. Only after hydration so we don't
- // overwrite stored values with the initial defaults on first mount.
useEffect(() => {
if (!advancedHydrated) return;
saveAdvanced({
@@ -344,52 +332,32 @@ export default function TranscriptSearch() {
const [groupsShown, setGroupsShown] = useState<number>(
DEFAULT_GROUPS_PAGE_SIZE,
);
- const pipelineRef = useRef<PipelineController | null>(null);
+ const pipelineRef = useRef<TreeController | null>(null);
const { openTranscript } = usePlayer();
- const openWithMode = (slug: string, start?: number) =>
- openTranscript(slug, start, { mode: isSubsMode ? "chat" : "transcript" });
- const trimmed = searchExecuted ? query.trim() : "";
-
- // Reset the live caps on each new search. Uses the currently configured
- // batch sizes so an advanced-options change only takes effect on the next
- // search (no jarring mid-search restarts when tuning).
+ // Reset the live caps on each new committed query.
useEffect(() => {
setHitLimit(hitBatchSize);
setGroupsShown(groupsBatchSize);
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [trimmed, useRegex]);
+ }, [committedHash]);
- // Per-channel group lookup driven by the active manifest. Falls back to
- // the synthesized default group when the manifest has no group metadata
- // (older builds) — keeps the UI working on stale manifests.
+ // ─── Channel-grouping setup (unchanged) ───────────────────────────────────
const manifestGroups = useMemo<ChannelGroup[]>(() => {
- const src = isSubsMode ? subsManifest?.groups : manifest?.groups;
+ const src = manifest?.groups;
if (src && src.length > 0) return src;
return [{ ...FALLBACK_GROUP }];
- }, [isSubsMode, manifest, subsManifest]);
+ }, [manifest]);
const manifestDefaultGroupId = useMemo<string>(() => {
- const dg = isSubsMode
- ? subsManifest?.defaultGroupId
- : manifest?.defaultGroupId;
+ const dg = manifest?.defaultGroupId;
if (typeof dg === "string" && manifestGroups.some((g) => g.id === dg)) {
return dg;
}
return manifestGroups[0]?.id ?? DEFAULT_GROUP_FALLBACK_ID;
- }, [isSubsMode, manifest, subsManifest, manifestGroups]);
+ }, [manifest, manifestGroups]);
const channelGroupByName = useMemo<Map<string, string>>(() => {
const map = new Map<string, string>();
- if (isSubsMode && subsManifest) {
- for (const c of subsManifest.channels) {
- if (!c.name) continue;
- map.set(
- c.name,
- resolveChannelGroupId(c.groupId, manifestGroups, manifestDefaultGroupId),
- );
- }
- return map;
- }
if (manifest?.channels.length) {
for (const c of manifest.channels) {
if (!c.name) continue;
@@ -400,22 +368,9 @@ export default function TranscriptSearch() {
}
}
return map;
- }, [
- isSubsMode,
- manifest,
- subsManifest,
- manifestGroups,
- manifestDefaultGroupId,
- ]);
+ }, [manifest, manifestGroups, manifestDefaultGroupId]);
const channelOptions = useMemo<string[]>(() => {
- if (isSubsMode) {
- if (!subsManifest) return [];
- return subsManifest.channels
- .map((c) => c.name)
- .filter((n) => n)
- .sort((a, b) => a.localeCompare(b));
- }
if (manifest?.channels.length) {
return manifest.channels
.map((c) => c.name)
@@ -426,10 +381,8 @@ export default function TranscriptSearch() {
const names = new Set<string>();
for (const t of transcripts) if (t.channel) names.add(t.channel);
return Array.from(names).sort((a, b) => a.localeCompare(b));
- }, [isSubsMode, manifest, subsManifest, transcripts]);
+ }, [manifest, transcripts]);
- // Bucketed view: each group + the channels currently in it. Empty groups
- // are hidden so the UI doesn't render a header with nothing inside.
const channelGroupings = useMemo<
{ group: ChannelGroup; channels: string[] }[]
>(() => {
@@ -448,13 +401,14 @@ export default function TranscriptSearch() {
for (const g of sorted) {
const channels = byId.get(g.id);
if (!channels || channels.length === 0) continue;
- out.push({ group: g, channels: channels.slice().sort((a, b) => a.localeCompare(b)) });
+ out.push({
+ group: g,
+ channels: channels.slice().sort((a, b) => a.localeCompare(b)),
+ });
}
return out;
}, [channelOptions, channelGroupByName, manifestGroups, manifestDefaultGroupId]);
- // Set of channels whose group has selectedByDefault === true. Used as the
- // baseline against which user deltas (included/excluded) are computed.
const defaultSelectedChannels = useMemo<Set<string>>(() => {
const out = new Set<string>();
const groupById = new Map(manifestGroups.map((g) => [g.id, g]));
@@ -491,147 +445,84 @@ export default function TranscriptSearch() {
committedNd,
]);
- // Stable signature of the filter inputs; used as a single dep in the search
- // pipeline so reference-unstable upstream values don't cancel an in-flight
- // search when other state changes.
- const filterKey = `${committedChannelsKey}|${committedNov ? 1 : 0}|${committedNol ? 1 : 0}|${committedNaa ? 1 : 0}|${committedNar ? 1 : 0}|${committedNav ? 1 : 0}|${committedNd ? 1 : 0}|${mode}|${committedTracksKey}`;
-
- const regex = useMemo<{ re: RegExp | null; error: string | null }>(() => {
- if (!trimmed || !useRegex) return { re: null, error: null };
- try {
- return { re: new RegExp(trimmed, "i"), error: null };
- } catch (e) {
- return { re: null, error: (e as Error).message };
- }
- }, [trimmed, useRegex]);
+ const filterKey = `${committedChannelsKey}|${committedNov ? 1 : 0}|${committedNol ? 1 : 0}|${committedNaa ? 1 : 0}|${committedNar ? 1 : 0}|${committedNav ? 1 : 0}|${committedNd ? 1 : 0}`;
- const titleMatchedSlugs = useMemo<Set<string>>(() => {
- const out = new Set<string>();
- if (!trimmed || !transcripts) return out;
- if (useRegex && !regex.re) return out;
- const re = useRegex && regex.re ? new RegExp(regex.re.source, "i") : null;
- const ql = re ? "" : trimmed.toLowerCase();
- for (const t of transcripts) {
- if (!passesFilter(t)) continue;
- if (isSubsMode && !allSubSlugs.has(t.slug)) continue;
- const hit = re ? re.test(t.title) : t.title.toLowerCase().includes(ql);
- if (hit) out.add(t.slug);
- }
+ const globalScopeSlugs = useMemo<string[]>(() => {
+ if (!transcripts) return [];
+ const out: string[] = [];
+ for (const t of transcripts) if (passesFilter(t)) out.push(t.slug);
return out;
- }, [
- trimmed,
- useRegex,
- regex.re,
- transcripts,
- passesFilter,
- isSubsMode,
- allSubSlugs,
- ]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [transcripts, filterKey]);
- // Pipeline lifecycle: create a new pipeline when inputs change, and cancel
- // the prior one. hitLimit is NOT a dep here — it's pushed into the live
- // pipeline via setHitLimit so bumping the cap resumes rather than restarts.
+ const hasActiveQuery = useMemo(
+ () => isNodeActive(committedRoot),
+ [committedRoot],
+ );
+
+ // ─── Run the query tree ────────────────────────────────────────────────────
+ // Re-run whenever the committed root changes, filters change, or the
+ // chat-scope slug set finishes loading. hitLimit is *not* a dep — bumped
+ // via setHitLimit on the controller so "Show more" resumes without
+ // restarting traversal.
useEffect(() => {
pipelineRef.current?.cancel();
pipelineRef.current = null;
- if (!trimmed) {
- setHitsBySlug({});
- setSubsHitsBySlug({});
- setTotalHits(0);
- setProcessed(0);
- setTotalToProcess(0);
- setPipelineDone(false);
- setCapped(false);
+ if (!hasActiveQuery) {
+ setTreeProgress(null);
return;
}
if (!transcripts) return;
- if (useRegex && !regex.re) return;
+ if (needsChatManifests && !subsManifestReady) return;
- if (isSubsMode) {
- if (!subsManifestReady) return;
- const slugs = filterSlugs(
- transcripts,
- (t) => passesFilter(t) && allSubSlugs.has(t.slug),
- );
- const controller = createSubsSearchPipeline({
- slugs,
- query: trimmed,
- useRegex,
- regex: regex.re,
- excludedTracks: liveChatOnlyExcludedTracks,
- initialHitLimit: hitLimit,
- concurrency: fetchConcurrency,
- flushIntervalMs,
- emit: (u) => {
- setSubsHitsBySlug(u.hitsBySlug);
- setTotalHits(u.totalHits);
- setProcessed(u.processed);
- setTotalToProcess(u.totalToProcess);
- setCapped(u.capped);
- setPipelineDone(u.done);
- },
- });
- pipelineRef.current = controller;
- return () => {
- controller.cancel();
- if (pipelineRef.current === controller) pipelineRef.current = null;
- };
- }
-
- const slugs = filterSlugs(transcripts, passesFilter);
- const controller = createSearchPipeline({
- slugs,
- query: trimmed,
- useRegex,
- regex: regex.re,
+ const controller = runQueryTree({
+ root: committedRoot,
+ globalScope: globalScopeSlugs,
+ summaries: transcripts,
+ chatScopeSlugs: needsChatManifests ? chatScopeSlugs : null,
initialHitLimit: hitLimit,
concurrency: fetchConcurrency,
flushIntervalMs,
- emit: (u) => {
- setHitsBySlug(u.hitsBySlug);
- setTotalHits(u.totalHits);
- setProcessed(u.processed);
- setTotalToProcess(u.totalToProcess);
- setCapped(u.capped);
- setPipelineDone(u.done);
- },
+ emit: (p) => setTreeProgress(p),
});
pipelineRef.current = controller;
-
return () => {
controller.cancel();
if (pipelineRef.current === controller) pipelineRef.current = null;
};
- // hitLimit intentionally omitted — bumped via setHitLimit below.
- // passesFilter captured via filterKey (content signature).
+ // hitLimit intentionally omitted — pushed via setHitLimit on the
+ // controller below. globalScopeSlugs identity already tracked via
+ // filterKey + transcripts. chatScopeSlugs identity ditto via
+ // chatScopeSlugsKey.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
- trimmed,
+ committedHash,
transcripts,
filterKey,
- useRegex,
- regex.re,
- isSubsMode,
+ needsChatManifests,
subsManifestReady,
- allSubSlugs,
- committedExcludedTracks,
+ fetchConcurrency,
+ flushIntervalMs,
]);
- // Push hitLimit bumps into the live pipeline so "Show more videos" resumes
- // the existing traversal instead of restarting from slug 0.
useEffect(() => {
pipelineRef.current?.setHitLimit(hitLimit);
}, [hitLimit]);
- const groups = useMemo<HitGroup[]>(() => {
- if (isSubsMode) return [];
- if (!trimmed || !transcripts) return [];
- const out: HitGroup[] = [];
+ // ─── Build the result list ────────────────────────────────────────────────
+ // Walk the summaries in their natural (newest-first) order, surfacing only
+ // those slugs that survived the query tree. For each, attach the per-leaf
+ // hits collected by searchEval. Hits are already sorted by start time
+ // within each video.
+ const resultGroups = useMemo<ResultGroup[]>(() => {
+ if (!hasActiveQuery) return [];
+ if (!transcripts || !treeProgress) return [];
+ const matched = treeProgress.slugs;
+ const hitsBySlug = treeProgress.hits;
+ const out: ResultGroup[] = [];
for (const t of transcripts) {
- const hits = hitsBySlug[t.slug];
- const titleMatch = titleMatchedSlugs.has(t.slug);
- if (!hits?.length && !titleMatch) continue;
+ if (!matched.has(t.slug)) continue;
out.push({
slug: t.slug,
title: t.title,
@@ -639,39 +530,40 @@ export default function TranscriptSearch() {
date: t.date,
isLivestream: t.isLivestream,
ageRestricted: t.ageRestricted,
- titleMatch: titleMatch ? t.title : undefined,
- hits: hits ?? [],
+ hits: hitsBySlug.get(t.slug) ?? [],
});
}
return out;
- }, [isSubsMode, trimmed, transcripts, hitsBySlug, titleMatchedSlugs]);
-
- const subsGroups = useMemo<SubsHitGroup[]>(() => {
- if (!isSubsMode) return [];
- if (!trimmed || !transcripts) return [];
- const out: SubsHitGroup[] = [];
- for (const t of transcripts) {
- const hits = subsHitsBySlug[t.slug];
- const titleMatch = titleMatchedSlugs.has(t.slug);
- if (!hits?.length && !titleMatch) continue;
- out.push({
- slug: t.slug,
- title: t.title,
- channel: t.channel,
- date: t.date,
- isLivestream: t.isLivestream,
- ageRestricted: t.ageRestricted,
- titleMatch: titleMatch ? t.title : undefined,
- hits: hits ?? [],
- });
+ }, [hasActiveQuery, transcripts, treeProgress]);
+
+ const leafStates = treeProgress?.leafStates ?? new Map<string, LeafState>();
+ const groupStates =
+ treeProgress?.groupStates ?? new Map<string, GroupState>();
+ const totalHits = useMemo(() => {
+ let n = 0;
+ for (const list of treeProgress?.hits?.values() ?? []) n += list.length;
+ return n;
+ }, [treeProgress]);
+ const capped = treeProgress?.capped ?? false;
+ const pipelineDone = treeProgress?.done ?? !hasActiveQuery;
+ const totalToProcess = globalScopeSlugs.length;
+ const processed = useMemo(() => {
+ if (!treeProgress) return 0;
+ // For "1/N searched" the user really wants to know root-level progress.
+ // Use the maximum over all leaves' `processed` since AND chains evaluate
+ // sequentially and the bottleneck leaf is the front-runner indicator.
+ let max = 0;
+ for (const s of treeProgress.leafStates.values()) {
+ if (s.totalToProcess === 0) continue;
+ const frac = s.processed / s.totalToProcess;
+ max = Math.max(max, frac * totalToProcess);
}
- return out;
- }, [isSubsMode, trimmed, transcripts, subsHitsBySlug, titleMatchedSlugs]);
+ return Math.round(max);
+ }, [treeProgress, totalToProcess]);
const indexLoading =
- Boolean(trimmed) &&
- (!summariesReady || (isSubsMode && !subsManifestReady));
- const searching = Boolean(trimmed) && totalToProcess > 0;
+ hasActiveQuery && (!summariesReady || (needsChatManifests && !subsManifestReady));
+ const searching = hasActiveQuery && totalToProcess > 0;
const pipelineActive = searching && !pipelineDone;
const indexProgress =
pageCount > 0 ? `${loadedPages}/${pageCount} pages` : "";
@@ -683,14 +575,8 @@ export default function TranscriptSearch() {
draftNaa !== committedNaa ||
draftNar !== committedNar ||
draftNav !== committedNav ||
- draftNd !== committedNd ||
- draftRegex !== useRegex ||
- (isSubsMode &&
- !sameSet(draftExcludedTracks, committedExcludedTracks));
- const queryDirty = input.trim() !== (searchExecuted ? query.trim() : "");
-
- // Snapshot of the currently-committed filter state, in storage format.
- // Used to detect profile dirtiness vs the in-memory committed state.
+ draftNd !== committedNd;
+
const committedSnapshot = useMemo<FilterSnapshot>(() => {
const deltas = buildSnapshotDeltas(
committedChannels,
@@ -704,10 +590,7 @@ export default function TranscriptSearch() {
if (committedNar) snap.nar = true;
if (committedNav) snap.nav = true;
if (committedNd) snap.nd = true;
- if (mode === "subs") snap.mode = "subs";
- if (committedExcludedTracks.size > 0) {
- snap.tracks = Array.from(committedExcludedTracks).sort();
- }
+ snap.query = stringifyRoot(committedRoot);
return snap;
}, [
committedChannels,
@@ -719,13 +602,9 @@ export default function TranscriptSearch() {
committedNar,
committedNav,
committedNd,
- mode,
- committedExcludedTracks,
+ committedRoot,
]);
- // Snapshot built from the *draft* state, used by Save/Save-as so the
- // profile captures what the user currently sees in the UI rather than
- // whatever was last committed via Enter.
const buildDraftSnapshot = useCallback((): FilterSnapshot => {
const snap: FilterSnapshot = {
channels: buildSnapshotDeltas(
@@ -740,10 +619,7 @@ export default function TranscriptSearch() {
if (draftNar) snap.nar = true;
if (draftNav) snap.nav = true;
if (draftNd) snap.nd = true;
- if (mode === "subs") snap.mode = "subs";
- if (isSubsMode && draftExcludedTracks.size > 0) {
- snap.tracks = Array.from(draftExcludedTracks).sort();
- }
+ snap.query = stringifyRoot(draftRoot);
return snap;
}, [
draftExcludedChannels,
@@ -755,14 +631,9 @@ export default function TranscriptSearch() {
draftNar,
draftNav,
draftNd,
- mode,
- isSubsMode,
- draftExcludedTracks,
+ draftRoot,
]);
- // Promote draft filter state to committed (in-memory) and strip any
- // filter-related URL params. Extracted from commitSearch so profile
- // save/save-as can refresh search without touching the query.
const promoteDraftsToCommitted = useCallback(() => {
setCommittedExcludedChannels(new Set(draftExcludedChannels));
setCommittedNov(draftNov);
@@ -771,10 +642,6 @@ export default function TranscriptSearch() {
setCommittedNar(draftNar);
setCommittedNav(draftNav);
setCommittedNd(draftNd);
- // Live-chat tab is scoped to live_chat only; per-track exclusions are
- // never user-controllable here so committed tracks always clear.
- setCommittedExcludedTracks(new Set());
- writeUrlParams({ re: draftRegex });
stripAllFilterParamsFromUrl();
}, [
draftExcludedChannels,
@@ -784,16 +651,15 @@ export default function TranscriptSearch() {
draftNar,
draftNav,
draftNd,
- draftRegex,
]);
- // Hydrate once the channel list is available. Resolution order:
- // 1. New share-link schema (`?fv=1&fc=…`) wins.
- // 2. Legacy URL filter params (`?ch=&nov=…`) — kept for backward-compat;
- // will be removed in a future cleanup.
- // 3. Otherwise, the active profile (or working snapshot) from localStorage.
- // The URL is NOT updated as a side effect of hydration — filters live in
- // React state now, and only the explicit Share button writes filter params.
+ // ─── Hydration ────────────────────────────────────────────────────────────
+ // Resolution order:
+ // 1. `qt=` in URL (modern composite-query share).
+ // 2. Legacy `q/m/re` URL (auto-migrate to a single-leaf root).
+ // 3. Share-link v1 schema (`?fv=1&fc=…`) for global filters only.
+ // 4. Legacy URL filter params (`?ch=&nov=…`).
+ // 5. Otherwise, active profile / working snapshot in localStorage.
useEffect(() => {
if (hydrated) return;
if (channelOptions.length === 0) return;
@@ -804,91 +670,95 @@ export default function TranscriptSearch() {
setCollapsedGroups(new Set(stored.collapsedGroups ?? []));
}
const search = typeof window !== "undefined" ? window.location.search : "";
+ const params = new URLSearchParams(search);
+
+ // Query tree.
+ const qt = params.get("qt");
+ let rootFromUrl: GroupNode | null = null;
+ if (qt) {
+ rootFromUrl = parseRoot(qt);
+ } else if (legacyQ || legacyMode === "subs") {
+ rootFromUrl = rootFromLegacy(legacyQ, legacyMode, legacyRe);
+ }
+
+ // Global filters (share-v1 wins over legacy keys wins over snapshot).
+ let initialExcluded: Set<string> | null = null;
+ let initialNov = false;
+ let initialNol = false;
+ let initialNaa = false;
+ let initialNar = false;
+ let initialNav = false;
+ let initialNd = false;
+
if (hasShareV1(search)) {
const sel = parseShareV1(search, channelOptions);
const excluded = new Set<string>();
for (const name of channelOptions) {
if (!sel.selectedChannels.has(name)) excluded.add(name);
}
- setDraftExcludedChannels(excluded);
- setDraftNov(!sel.videos);
- setDraftNol(!sel.livestreams);
- setDraftNaa(!sel.allAges);
- setDraftNar(!sel.restricted);
- setDraftNav(!sel.available);
- setDraftNd(!sel.deleted);
- setDraftExcludedTracks(new Set(sel.tracks));
- setCommittedExcludedChannels(excluded);
- setCommittedNov(!sel.videos);
- setCommittedNol(!sel.livestreams);
- setCommittedNaa(!sel.allAges);
- setCommittedNar(!sel.restricted);
- setCommittedNav(!sel.available);
- setCommittedNd(!sel.deleted);
- setCommittedExcludedTracks(new Set(sel.tracks));
- setHydrated(true);
- return;
+ initialExcluded = excluded;
+ initialNov = !sel.videos;
+ initialNol = !sel.livestreams;
+ initialNaa = !sel.allAges;
+ initialNar = !sel.restricted;
+ initialNav = !sel.available;
+ initialNd = !sel.deleted;
+ } else if (urlHasAnyFilterParam()) {
+ const excluded = new Set(params.getAll("ch"));
+ initialExcluded = excluded;
+ initialNov = params.get("nov") === "1";
+ initialNol = params.get("nol") === "1";
+ initialNaa = params.get("naa") === "1";
+ initialNar = params.get("nar") === "1";
+ initialNav = params.get("nav") === "1";
+ initialNd = params.get("nd") === "1";
}
- if (urlHasAnyFilterParam()) {
- // Legacy schema: parse the URL the old way, seed committed + draft.
- const p = new URLSearchParams(search);
- const excluded = new Set(p.getAll("ch"));
- const lNov = p.get("nov") === "1";
- const lNol = p.get("nol") === "1";
- const lNaa = p.get("naa") === "1";
- const lNar = p.get("nar") === "1";
- const lNav = p.get("nav") === "1";
- const lNd = p.get("nd") === "1";
- const lTracks = new Set(p.getAll("tk"));
- setDraftExcludedChannels(excluded);
- setDraftNov(lNov);
- setDraftNol(lNol);
- setDraftNaa(lNaa);
- setDraftNar(lNar);
- setDraftNav(lNav);
- setDraftNd(lNd);
- setDraftExcludedTracks(lTracks);
- setCommittedExcludedChannels(excluded);
- setCommittedNov(lNov);
- setCommittedNol(lNol);
- setCommittedNaa(lNaa);
- setCommittedNar(lNar);
- setCommittedNav(lNav);
- setCommittedNd(lNd);
- setCommittedExcludedTracks(lTracks);
- setHydrated(true);
- return;
+
+ let resolvedRoot = rootFromUrl;
+ if (!initialExcluded) {
+ // Fall back to stored profile / working snapshot.
+ const snapshot =
+ stored?.activeProfileName != null
+ ? stored.profiles[stored.activeProfileName] ?? stored.working
+ : stored?.working ?? null;
+ const excluded = snapshotToExcluded(
+ snapshot,
+ channelOptions,
+ defaultSelectedChannels,
+ );
+ initialExcluded = excluded;
+ initialNov = snapshot?.nov === true;
+ initialNol = snapshot?.nol === true;
+ initialNaa = snapshot?.naa === true;
+ initialNar = snapshot?.nar === true;
+ initialNav = snapshot?.nav === true;
+ initialNd = snapshot?.nd === true;
+ if (!resolvedRoot && snapshot?.query) {
+ resolvedRoot = parseRoot(snapshot.query);
+ }
}
- const snapshot =
- stored?.activeProfileName != null
- ? stored.profiles[stored.activeProfileName] ?? stored.working
- : stored?.working ?? null;
- const excluded = snapshotToExcluded(
- snapshot,
- channelOptions,
- defaultSelectedChannels,
- );
- setDraftExcludedChannels(excluded);
- setCommittedExcludedChannels(excluded);
- if (snapshot) {
- setDraftNov(snapshot.nov === true);
- setDraftNol(snapshot.nol === true);
- setDraftNaa(snapshot.naa === true);
- setDraftNar(snapshot.nar === true);
- setDraftNav(snapshot.nav === true);
- setDraftNd(snapshot.nd === true);
- setDraftExcludedTracks(new Set(snapshot.tracks ?? []));
- setCommittedNov(snapshot.nov === true);
- setCommittedNol(snapshot.nol === true);
- setCommittedNaa(snapshot.naa === true);
- setCommittedNar(snapshot.nar === true);
- setCommittedNav(snapshot.nav === true);
- setCommittedNd(snapshot.nd === true);
- setCommittedExcludedTracks(new Set(snapshot.tracks ?? []));
+
+ if (resolvedRoot) {
+ setDraftRoot(resolvedRoot);
+ setCommittedRoot(resolvedRoot);
+ setSearchExecuted(true);
}
- if (snapshot?.mode === "subs") writeUrlParams({ mode: "subs" });
+
+ setDraftExcludedChannels(initialExcluded);
+ setDraftNov(initialNov);
+ setDraftNol(initialNol);
+ setDraftNaa(initialNaa);
+ setDraftNar(initialNar);
+ setDraftNav(initialNav);
+ setDraftNd(initialNd);
+ setCommittedExcludedChannels(initialExcluded);
+ setCommittedNov(initialNov);
+ setCommittedNol(initialNol);
+ setCommittedNaa(initialNaa);
+ setCommittedNar(initialNar);
+ setCommittedNav(initialNav);
+ setCommittedNd(initialNd);
setHydrated(true);
- // Run exactly once when the channel list becomes known.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [channelOptions.length === 0, hydrated]);
@@ -914,23 +784,26 @@ export default function TranscriptSearch() {
const commitSearch = () => {
setSearchExecuted(true);
+ setCommittedRoot(draftRoot);
const nextSnapshot = buildDraftSnapshot();
- // Persist as the working snapshot. The active profile stays the same;
- // the UI shows "(unsaved)" until the user saves explicitly.
const current = loadStoredState() ?? emptyStoredState();
current.working = nextSnapshot;
current.profiles = profiles;
current.activeProfileName = activeProfileName;
saveStoredState(current);
- // Promote drafts to committed (in-memory) and strip any filter-related
- // URL params left over from a share link. Only the search query and
- // regex toggle are persisted on the URL.
promoteDraftsToCommitted();
- writeUrlParams({ q: input.trim() });
+ // Write the URL: `qt=` is canonical. Strip legacy `q`/`m`/`re` so old
+ // params don't clobber the next reload's tree parse.
+ const serialized = stringifyRoot(draftRoot);
+ if (isNodeActive(draftRoot)) {
+ writeUrlParams({ q: "", re: false, mode: "transcripts" });
+ writeQtParam(serialized);
+ } else {
+ writeUrlParams({ q: "", re: false, mode: "transcripts" });
+ writeQtParam("");
+ }
};
- // Apply an arbitrary snapshot to *draft* state only. Used by reset flows
- // that should defer search re-run until the user clicks Search / Enter.
const applyDraftSnapshot = useCallback(
(snapshot: FilterSnapshot | null) => {
const excluded = snapshotToExcluded(
@@ -945,13 +818,14 @@ export default function TranscriptSearch() {
setDraftNar(snapshot?.nar === true);
setDraftNav(snapshot?.nav === true);
setDraftNd(snapshot?.nd === true);
- setDraftExcludedTracks(new Set(snapshot?.tracks ?? []));
+ if (snapshot?.query) {
+ const parsed = parseRoot(snapshot.query);
+ if (parsed) setDraftRoot(parsed);
+ }
},
[channelOptions, defaultSelectedChannels],
);
- // Apply an arbitrary snapshot to draft state AND immediately commit it.
- // Used by profile selection (explicit "I want this now" intent).
const applySnapshot = useCallback(
(snapshot: FilterSnapshot | null) => {
applyDraftSnapshot(snapshot);
@@ -967,10 +841,10 @@ export default function TranscriptSearch() {
setCommittedNar(snapshot?.nar === true);
setCommittedNav(snapshot?.nav === true);
setCommittedNd(snapshot?.nd === true);
- setCommittedExcludedTracks(new Set(snapshot?.tracks ?? []));
- writeUrlParams({
- mode: snapshot?.mode === "subs" ? "subs" : "transcripts",
- });
+ if (snapshot?.query) {
+ const parsed = parseRoot(snapshot.query);
+ if (parsed) setCommittedRoot(parsed);
+ }
stripAllFilterParamsFromUrl();
},
[applyDraftSnapshot, channelOptions, defaultSelectedChannels],
@@ -988,11 +862,9 @@ export default function TranscriptSearch() {
[profiles, activeProfileName, collapsedGroups],
);
- // Profile CRUD. Each mutation updates React state AND localStorage.
const handleLoadProfile = useCallback(
(name: string | "") => {
if (name === "") {
- // Switch to the working snapshot.
setActiveProfileName(null);
writeStorage((s) => {
s.activeProfileName = null;
@@ -1092,16 +964,11 @@ export default function TranscriptSearch() {
setActiveProfileName(null);
}, [activeProfileName, writeStorage]);
- // True iff a profile is loaded AND the committed state diverges from it.
const profileDirty =
activeProfileName != null &&
profiles[activeProfileName] != null &&
!snapshotsEqual(profiles[activeProfileName], committedSnapshot);
- // Reset channel selection back to group defaults. Leaves other filters
- // untouched. Does NOT touch the active profile pointer — committing this
- // change will just mark the profile dirty. Draft-only: the user must
- // press Search / Enter to apply.
const handleResetChannels = useCallback(() => {
const excluded = new Set<string>();
for (const name of channelOptions) {
@@ -1110,9 +977,6 @@ export default function TranscriptSearch() {
setDraftExcludedChannels(excluded);
}, [channelOptions, defaultSelectedChannels]);
- // Reset every draft filter to a fresh state AND clear active profile.
- // Collapse state is preserved (UI-only preference). Draft-only: the user
- // must press Search / Enter to actually re-run the pipeline.
const handleResetAllFilters = useCallback(() => {
setActiveProfileName(null);
writeStorage((s) => {
@@ -1124,8 +988,6 @@ export default function TranscriptSearch() {
const [shareCopied, setShareCopied] = useState(false);
const shareResetRef = useRef<number | null>(null);
- // Build a positive-encoding share link from the *committed* state. Drafts
- // that haven't been committed aren't part of "the current search" yet.
const handleShareCurrentSearch = useCallback(async () => {
const selectedChannels = new Set<string>();
for (const name of channelOptions) {
@@ -1139,13 +1001,16 @@ export default function TranscriptSearch() {
restricted: !committedNar,
available: !committedNav,
deleted: !committedNd,
- tracks: committedExcludedTracks,
+ tracks: new Set(),
};
const params = buildShareSearchParams(selection, {
- q: query,
- mode,
- regex: useRegex,
+ q: "",
+ mode: "transcripts",
+ regex: false,
});
+ if (isNodeActive(committedRoot)) {
+ params.set("qt", stringifyRoot(committedRoot));
+ }
const url = `${window.location.origin}${window.location.pathname}?${params.toString()}`;
try {
await navigator.clipboard.writeText(url);
@@ -1169,19 +1034,11 @@ export default function TranscriptSearch() {
committedNar,
committedNav,
committedNd,
- committedExcludedTracks,
- query,
- mode,
- useRegex,
+ committedRoot,
]);
- // Select-all / select-none helpers. Operate on either every channel or
- // a single group's channels.
const setSelected = useCallback(
- (
- targets: ReadonlyArray<string>,
- selected: boolean,
- ) => {
+ (targets: ReadonlyArray<string>, selected: boolean) => {
setDraftExcludedChannels((prev) => {
const next = new Set(prev);
if (selected) {
@@ -1195,82 +1052,68 @@ export default function TranscriptSearch() {
[],
);
+ const openWithMode = (slug: string, hit?: LayerHit) => {
+ const modalMode = hit?.scope === "chat" ? "chat" : "transcript";
+ openTranscript(slug, hit?.start, { mode: modalMode });
+ };
+
+ // Map of leafId → leaf (and its query) so result-row rendering can look
+ // up the original search terms for `<mark>` highlighting.
+ const leavesById = useMemo(() => {
+ const m = new Map<
+ string,
+ {
+ query: string;
+ useRegex: boolean;
+ scope: "transcripts" | "chat" | "metadata";
+ }
+ >();
+ forEachLeaf(committedRoot, (leaf) =>
+ m.set(leaf.id, {
+ query: leaf.query,
+ useRegex: leaf.useRegex,
+ scope: leaf.scope,
+ }),
+ );
+ return m;
+ }, [committedRoot]);
+
+ // Hits split by leaf, in the order leaves appear in the tree, so the UI
+ // sections render in a stable order regardless of the order findHitsInCues
+ // discovered them.
+ const orderedLeafIds = useMemo<string[]>(() => {
+ const ids: string[] = [];
+ forEachLeaf(committedRoot, (leaf) => {
+ if (leaf.contributeHits) ids.push(leaf.id);
+ });
+ return ids;
+ }, [committedRoot]);
+
return (
<div className="flex flex-col gap-6">
- {hasSubs && (
- <div
- role="tablist"
- aria-label="Search mode"
- className="flex gap-1 border-b border-zinc-200 dark:border-zinc-800 text-sm"
- >
- <button
- type="button"
- role="tab"
- aria-selected={!isSubsMode}
- onClick={() => {
- if (!isSubsMode) return;
- writeUrlParams({ mode: "transcripts" });
- }}
- className={`px-3 py-2 -mb-px border-b-2 ${
- isSubsMode
- ? "border-transparent text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
- : "border-blue-500 text-zinc-900 dark:text-zinc-100 font-medium"
- }`}
- >
- Transcripts
- </button>
- <button
- type="button"
- role="tab"
- aria-selected={isSubsMode}
- onClick={() => {
- if (isSubsMode) return;
- writeUrlParams({ mode: "subs" });
- }}
- className={`px-3 py-2 -mb-px border-b-2 ${
- isSubsMode
- ? "border-blue-500 text-zinc-900 dark:text-zinc-100 font-medium"
- : "border-transparent text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
- }`}
- >
- Live chat
- <span className="ml-1.5 text-xs text-zinc-500">
- ({liveChatTotalCount})
- </span>
- </button>
- </div>
- )}
<form
onSubmit={(e) => {
e.preventDefault();
commitSearch();
}}
- className="flex flex-col gap-1 -mt-3"
+ className="flex flex-col gap-3"
+ data-testid="search-form"
>
- <div className="flex gap-2">
- <input
- type="search"
- value={input}
- onChange={(e) => setInput(e.target.value)}
- placeholder={
- draftRegex
- ? "Regex pattern (case-insensitive)…"
- : "Search transcripts..."
- }
- className={`flex-1 rounded-lg border ${
- regex.error
- ? "border-red-500"
- : queryDirty || filtersDirty
- ? "border-amber-400 ring-1 ring-amber-300/60 dark:ring-amber-500/40"
- : "border-zinc-300 dark:border-zinc-700"
- } bg-white dark:bg-zinc-900 px-4 py-3 text-base outline-none focus:ring-2 focus:ring-blue-500 ${
- draftRegex ? "font-mono" : ""
- }`}
+ {mounted ? (
+ <QueryBuilder
+ root={draftRoot}
+ onChange={setDraftRoot}
+ leafStates={leafStates}
+ groupStates={groupStates}
/>
+ ) : (
+ <div className="h-12 rounded-lg border border-zinc-300 dark:border-zinc-700 bg-zinc-100 dark:bg-zinc-900/40" />
+ )}
+ <div className="flex items-center gap-2 flex-wrap">
<button
type="submit"
- aria-label="Search"
- className={`shrink-0 px-4 py-3 rounded-lg text-sm font-medium transition-colors ${
+ data-testid="search-submit"
+ className={`shrink-0 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
queryDirty || filtersDirty
? "bg-blue-600 text-white hover:bg-blue-700"
: "border border-zinc-300 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800"
@@ -1278,26 +1121,21 @@ export default function TranscriptSearch() {
>
Search
</button>
- </div>
- <label className="flex items-center gap-2 text-sm text-zinc-600 dark:text-zinc-400 select-none">
- <input
- type="checkbox"
- checked={draftRegex}
- onChange={(e) => setDraftRegex(e.target.checked)}
- className="accent-blue-600"
- />
- Regex
- {regex.error && (
- <span className="text-red-600 font-mono text-xs">
- {regex.error}
- </span>
- )}
{(queryDirty || filtersDirty) && (
- <span className="text-xs text-amber-600 dark:text-amber-400 ml-auto">
+ <span className="text-xs text-amber-600 dark:text-amber-400">
Press Enter or click Search to apply
</span>
)}
- </label>
+ {hasSubs && (
+ <span
+ className="text-xs text-zinc-400 ml-auto"
+ title={`${liveChatTotalCount} videos have live chat`}
+ >
+ Live chat available on {liveChatTotalCount} video
+ {liveChatTotalCount === 1 ? "" : "s"} — try scope: Live chat.
+ </span>
+ )}
+ </div>
</form>
{channelOptions.length > 0 && (
@@ -1514,9 +1352,6 @@ export default function TranscriptSearch() {
</div>
)}
- {/* Track filter UI intentionally hidden in subs mode: the tab is
- scoped to live_chat only — see force-exclusion in the pipeline. */}
-
<details className="-mt-3 text-sm text-zinc-600 dark:text-zinc-400">
<summary className="cursor-pointer select-none text-xs uppercase tracking-wide text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300">
Advanced options
@@ -1590,146 +1425,27 @@ export default function TranscriptSearch() {
</div>
</details>
- {!trimmed && (
+ {!hasActiveQuery && (
<p className="text-sm text-zinc-500">
- {isSubsMode
- ? "Enter a query to search live chat."
- : "Enter a query to search transcripts."}
+ Add a layer above to search. Combine layers with AND / OR to narrow
+ or widen results.
</p>
)}
- {trimmed && !isSubsMode && (
- <>
- <section>
- <h2 className="text-sm font-medium text-zinc-500 mb-2 flex items-center gap-2">
- <span>
- Transcript mentions{" "}
- {totalHits > 0
- ? `(${totalHits}${capped ? "+" : ""} mention${
- totalHits === 1 ? "" : "s"
- } across ${groups.length} video${
- groups.length === 1 ? "" : "s"
- })`
- : "(0)"}
- </span>
- {indexLoading && (
- <span className="text-xs text-zinc-400 font-normal">
- loading index{indexProgress ? ` (${indexProgress})` : ""}…
- </span>
- )}
- {searching && (
- <span className="text-xs text-zinc-400 font-normal">
- searched {processed}/{totalToProcess}
- {pipelineActive ? "…" : ""}
- </span>
- )}
- </h2>
- {!indexLoading && groups.length === 0 && !pipelineActive && (
- <p className="text-sm text-zinc-500">No transcript mentions.</p>
- )}
- {groups.length > 0 && (
- <div className="flex flex-col gap-3">
- {groups.slice(0, groupsShown).map((g) => (
- <div
- key={g.slug}
- className="border border-zinc-200 dark:border-zinc-800 rounded-lg overflow-hidden bg-white dark:bg-zinc-900"
- >
- <button
- type="button"
- onClick={() => openWithMode(g.slug)}
- className="w-full text-left px-4 py-2 bg-zinc-50 dark:bg-zinc-900/60 border-b border-zinc-200 dark:border-zinc-800 hover:bg-zinc-100 dark:hover:bg-zinc-800 flex items-baseline gap-2"
- >
- <span className="font-medium truncate flex-1 min-w-0">
- {g.title}
- </span>
- {g.isLivestream && <LivestreamBadge />}
- {g.ageRestricted && <AgeRestrictedBadge />}
- <span className="text-xs text-zinc-500 shrink-0">
- {g.channel && `${g.channel} · `}
- {g.date}
- {` · ${g.hits.length} mention${g.hits.length === 1 ? "" : "s"}`}
- </span>
- </button>
- <ul className="flex flex-col divide-y divide-zinc-200 dark:divide-zinc-800">
- {g.titleMatch && (
- <li>
- <button
- type="button"
- onClick={() => openWithMode(g.slug)}
- className="w-full text-left flex gap-3 px-4 py-2 hover:bg-zinc-50 dark:hover:bg-zinc-800"
- >
- <TitleBadge />
- <span className="flex-1 min-w-0 text-sm">
- {highlight(g.titleMatch, trimmed, useRegex)}
- </span>
- </button>
- </li>
- )}
- {g.hits.map((h, i) => {
- const isActive =
- activeVideo === g.slug && activeTime === h.start;
- return (
- <li key={i}>
- <button
- type="button"
- onClick={() => openWithMode(g.slug, h.start)}
- className={`w-full text-left flex gap-3 px-4 py-2 hover:bg-zinc-50 dark:hover:bg-zinc-800 ${
- isActive
- ? "bg-amber-100/70 dark:bg-amber-500/10 ring-1 ring-inset ring-amber-400/60"
- : ""
- }`}
- >
- <span className="text-xs font-mono text-zinc-500 shrink-0 w-16">
- {formatSeconds(h.start)}
- </span>
- <span className="flex-1 min-w-0 text-sm">
- {highlight(h.text, trimmed, useRegex)}
- </span>
- </button>
- </li>
- );
- })}
- </ul>
- </div>
- ))}
- </div>
- )}
- {(groups.length > groupsShown || capped) && (
- <div className="mt-3 flex justify-center">
- <button
- type="button"
- onClick={() => {
- setGroupsShown((n) => n + groupsBatchSize);
- if (capped) setHitLimit((l) => l + hitBatchSize);
- }}
- className="px-4 py-2 rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 hover:bg-zinc-50 dark:hover:bg-zinc-800 text-sm"
- >
- {groups.length > groupsShown
- ? `Show more videos (${groups.length - groupsShown} loaded${capped ? ", more available" : ""})`
- : "Show more videos"}
- </button>
- </div>
- )}
- </section>
- </>
- )}
-
- {trimmed && isSubsMode && (
- <section>
- <h2 className="text-sm font-medium text-zinc-500 mb-2 flex items-center gap-2">
- <span>
- Live chat mentions{" "}
- {totalHits > 0
- ? `(${totalHits}${capped ? "+" : ""} mention${
- totalHits === 1 ? "" : "s"
- } across ${subsGroups.length} video${
- subsGroups.length === 1 ? "" : "s"
- })`
+ {hasActiveQuery && (
+ <section data-testid="results-section">
+ <h2 className="text-sm font-medium text-zinc-500 mb-2 flex items-center gap-2 flex-wrap">
+ <span data-testid="results-summary">
+ Matching videos{" "}
+ {resultGroups.length > 0
+ ? `(${resultGroups.length} video${
+ resultGroups.length === 1 ? "" : "s"
+ }${totalHits > 0 ? `, ${totalHits}${capped ? "+" : ""} hit${totalHits === 1 ? "" : "s"}` : ""})`
: "(0)"}
</span>
{indexLoading && (
<span className="text-xs text-zinc-400 font-normal">
- loading live chat index…
+ loading index{indexProgress ? ` (${indexProgress})` : ""}…
</span>
)}
{searching && (
@@ -1739,78 +1455,25 @@ export default function TranscriptSearch() {
</span>
)}
</h2>
- {!indexLoading && subsGroups.length === 0 && !pipelineActive && (
- <p className="text-sm text-zinc-500">No live chat mentions.</p>
+ {!indexLoading && resultGroups.length === 0 && !pipelineActive && (
+ <p className="text-sm text-zinc-500">No matching videos.</p>
)}
- {subsGroups.length > 0 && (
+ {resultGroups.length > 0 && (
<div className="flex flex-col gap-3">
- {subsGroups.slice(0, groupsShown).map((g) => (
- <div
+ {resultGroups.slice(0, groupsShown).map((g) => (
+ <ResultCard
key={g.slug}
- className="border border-zinc-200 dark:border-zinc-800 rounded-lg overflow-hidden bg-white dark:bg-zinc-900"
- >
- <button
- type="button"
- onClick={() => openWithMode(g.slug)}
- className="w-full text-left px-4 py-2 bg-zinc-50 dark:bg-zinc-900/60 border-b border-zinc-200 dark:border-zinc-800 hover:bg-zinc-100 dark:hover:bg-zinc-800 flex items-baseline gap-2"
- >
- <span className="font-medium truncate flex-1 min-w-0">
- {g.title}
- </span>
- {g.isLivestream && <LivestreamBadge />}
- {g.ageRestricted && <AgeRestrictedBadge />}
- <span className="text-xs text-zinc-500 shrink-0">
- {g.channel && `${g.channel} · `}
- {g.date}
- {` · ${g.hits.length} mention${g.hits.length === 1 ? "" : "s"}`}
- </span>
- </button>
- <ul className="flex flex-col divide-y divide-zinc-200 dark:divide-zinc-800">
- {g.titleMatch && (
- <li>
- <button
- type="button"
- onClick={() => openWithMode(g.slug)}
- className="w-full text-left flex gap-3 px-4 py-2 hover:bg-zinc-50 dark:hover:bg-zinc-800"
- >
- <TitleBadge />
- <span className="flex-1 min-w-0 text-sm">
- {highlight(g.titleMatch, trimmed, useRegex)}
- </span>
- </button>
- </li>
- )}
- {g.hits.map((h, i) => {
- const isActive =
- activeVideo === g.slug && activeTime === h.start;
- return (
- <li key={i}>
- <button
- type="button"
- onClick={() => openWithMode(g.slug, h.start)}
- className={`w-full text-left flex gap-3 px-4 py-2 hover:bg-zinc-50 dark:hover:bg-zinc-800 ${
- isActive
- ? "bg-amber-100/70 dark:bg-amber-500/10 ring-1 ring-inset ring-amber-400/60"
- : ""
- }`}
- >
- <span className="text-xs font-mono text-zinc-500 shrink-0 w-16">
- {formatSeconds(h.start)}
- </span>
- <TrackBadge track={h.track} />
- <span className="flex-1 min-w-0 text-sm">
- {highlight(h.text, trimmed, useRegex)}
- </span>
- </button>
- </li>
- );
- })}
- </ul>
- </div>
+ group={g}
+ leavesById={leavesById}
+ orderedLeafIds={orderedLeafIds}
+ activeVideo={activeVideo}
+ activeTime={activeTime}
+ openWithMode={openWithMode}
+ />
))}
</div>
)}
- {(subsGroups.length > groupsShown || capped) && (
+ {(resultGroups.length > groupsShown || capped) && (
<div className="mt-3 flex justify-center">
<button
type="button"
@@ -1820,8 +1483,8 @@ export default function TranscriptSearch() {
}}
className="px-4 py-2 rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 hover:bg-zinc-50 dark:hover:bg-zinc-800 text-sm"
>
- {subsGroups.length > groupsShown
- ? `Show more videos (${subsGroups.length - groupsShown} loaded${capped ? ", more available" : ""})`
+ {resultGroups.length > groupsShown
+ ? `Show more videos (${resultGroups.length - groupsShown} loaded${capped ? ", more available" : ""})`
: "Show more videos"}
</button>
</div>
@@ -1832,21 +1495,127 @@ export default function TranscriptSearch() {
);
}
-function TrackBadge({ track }: { track: string }) {
- const label = track === "live_chat" ? "live chat" : track;
+// ─── Result rendering ────────────────────────────────────────────────────────
+
+function ResultCard({
+ group,
+ leavesById,
+ orderedLeafIds,
+ activeVideo,
+ activeTime,
+ openWithMode,
+}: {
+ group: ResultGroup;
+ leavesById: ReadonlyMap<
+ string,
+ { query: string; useRegex: boolean; scope: "transcripts" | "chat" | "metadata" }
+ >;
+ orderedLeafIds: ReadonlyArray<string>;
+ activeVideo: string | null;
+ activeTime: number | null;
+ openWithMode: (slug: string, hit?: LayerHit) => void;
+}) {
+ // Bucket hits per contributing leaf so the user sees one section per
+ // layer rather than an interleaved mishmash.
+ const buckets = useMemo(() => {
+ const m = new Map<string, LayerHit[]>();
+ for (const id of orderedLeafIds) m.set(id, []);
+ for (const h of group.hits) {
+ const arr = m.get(h.leafId);
+ if (arr) arr.push(h);
+ }
+ return m;
+ }, [group.hits, orderedLeafIds]);
+
return (
- <span className="shrink-0 text-[10px] uppercase tracking-wide font-medium px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-300 self-center">
- {label}
- </span>
+ <div
+ className="border border-zinc-200 dark:border-zinc-800 rounded-lg overflow-hidden bg-white dark:bg-zinc-900"
+ data-result-slug={group.slug}
+ >
+ <button
+ type="button"
+ onClick={() => openWithMode(group.slug)}
+ className="w-full text-left px-4 py-2 bg-zinc-50 dark:bg-zinc-900/60 border-b border-zinc-200 dark:border-zinc-800 hover:bg-zinc-100 dark:hover:bg-zinc-800 flex items-baseline gap-2"
+ >
+ <span className="font-medium truncate flex-1 min-w-0">
+ {group.title}
+ </span>
+ {group.isLivestream && <LivestreamBadge />}
+ {group.ageRestricted && <AgeRestrictedBadge />}
+ <span className="text-xs text-zinc-500 shrink-0">
+ {group.channel && `${group.channel} · `}
+ {group.date}
+ {` · ${group.hits.length} hit${group.hits.length === 1 ? "" : "s"}`}
+ </span>
+ </button>
+ <ul className="flex flex-col divide-y divide-zinc-200 dark:divide-zinc-800">
+ {Array.from(buckets.entries()).map(([leafId, hits]) => {
+ if (hits.length === 0) return null;
+ const leafInfo = leavesById.get(leafId);
+ if (!leafInfo) return null;
+ return (
+ <li key={leafId}>
+ <div
+ className="flex items-center gap-2 px-4 py-1.5 bg-zinc-50/60 dark:bg-zinc-900/40 border-b border-zinc-200 dark:border-zinc-800"
+ data-leaf-section={leafId}
+ >
+ <LayerSwatch leafId={leafId} size="xs" />
+ <span className="text-[10px] uppercase tracking-wide text-zinc-500">
+ {leafInfo.scope === "metadata"
+ ? "Title / channel"
+ : leafInfo.scope === "chat"
+ ? "Live chat"
+ : "Transcripts"}
+ </span>
+ <span className="font-mono text-xs text-zinc-600 dark:text-zinc-400 truncate">
+ {leafInfo.query}
+ </span>
+ <span className="ml-auto text-[10px] text-zinc-500">
+ {hits.length} hit{hits.length === 1 ? "" : "s"}
+ </span>
+ </div>
+ <ul className="flex flex-col divide-y divide-zinc-200/60 dark:divide-zinc-800/60">
+ {hits.map((h, i) => {
+ const isActive =
+ activeVideo === group.slug && activeTime === h.start;
+ return (
+ <li key={i}>
+ <button
+ type="button"
+ onClick={() => openWithMode(group.slug, h)}
+ className={`w-full text-left flex gap-3 px-4 py-2 hover:bg-zinc-50 dark:hover:bg-zinc-800 ${
+ isActive
+ ? "bg-amber-100/70 dark:bg-amber-500/10 ring-1 ring-inset ring-amber-400/60"
+ : ""
+ }`}
+ >
+ <span className="text-xs font-mono text-zinc-500 shrink-0 w-16">
+ {h.scope === "metadata" ? "—" : formatSeconds(h.start)}
+ </span>
+ {h.track && h.track !== "live_chat" && (
+ <TrackBadge track={h.track} />
+ )}
+ <span className="flex-1 min-w-0 text-sm">
+ {highlight(h.text, leafInfo.query, leafInfo.useRegex)}
+ </span>
+ </button>
+ </li>
+ );
+ })}
+ </ul>
+ </li>
+ );
+ })}
+ </ul>
+ </div>
);
}
-function TitleBadge() {
+function TrackBadge({ track }: { track: string }) {
+ const label = track === "live_chat" ? "live chat" : track;
return (
- <span className="shrink-0 w-16 text-[10px] uppercase tracking-wide font-medium text-zinc-600 dark:text-zinc-300 self-center">
- <span className="px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-zinc-800">
- title
- </span>
+ <span className="shrink-0 text-[10px] uppercase tracking-wide font-medium px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-300 self-center">
+ {label}
</span>
);
}
@@ -2130,3 +1899,18 @@ function highlight(text: string, q: string, useRegex = false) {
}
return <>{parts}</>;
}
+
+// Write `qt=` directly. `writeUrlParams` doesn't know about the param —
+// keep that helper focused on the legacy/share keys and use a small
+// dedicated mutator here.
+function writeQtParam(value: string): void {
+ if (typeof window === "undefined") return;
+ const params = new URLSearchParams(window.location.search);
+ if (value) params.set("qt", value);
+ else params.delete("qt");
+ const qs = params.toString();
+ const next = `${window.location.pathname}${qs ? `?${qs}` : ""}`;
+ if (next === window.location.pathname + window.location.search) return;
+ window.history.replaceState(window.history.state, "", next);
+ window.dispatchEvent(new PopStateEvent("popstate"));
+}
diff --git a/common/components/exportFilterStorage.ts b/common/components/exportFilterStorage.ts
@@ -11,6 +11,13 @@ const VERSION = 1;
// Per-channel delta vs. group defaults. Storing the *override* (not the
// resolved selection) means newly-added channels naturally inherit their
// group's selectedByDefault without showing up as accidentally-unchecked.
+//
+// `query` is the JSON-stringified composite-search root (see
+// `common/lib/searchQuery.ts:stringifyRoot`). Persisted on profiles so a
+// saved profile carries both the filters AND the layered query.
+//
+// `mode` and `tracks` are vestigial — kept only to deserialize older v1
+// payloads without spuriously wiping them. New writes don't set them.
export type FilterSnapshot = {
channels: { included: string[]; excluded: string[] };
nov?: boolean;
@@ -21,6 +28,7 @@ export type FilterSnapshot = {
nd?: boolean;
mode?: SearchMode;
tracks?: string[];
+ query?: string;
};
export type StoredState = {
@@ -70,6 +78,7 @@ function parseSnapshot(raw: unknown): FilterSnapshot | null {
if (typeof r.nd === "boolean") snap.nd = r.nd;
if (r.mode === "transcripts" || r.mode === "subs") snap.mode = r.mode;
if (isStringArray(r.tracks)) snap.tracks = r.tracks.slice();
+ if (typeof r.query === "string") snap.query = r.query;
return snap;
}
@@ -173,5 +182,6 @@ export function snapshotsEqual(a: FilterSnapshot, b: FilterSnapshot): boolean {
if ((a.nd ?? false) !== (b.nd ?? false)) return false;
if ((a.mode ?? "transcripts") !== (b.mode ?? "transcripts")) return false;
if (sortedJson(a.tracks ?? []) !== sortedJson(b.tracks ?? [])) return false;
+ if ((a.query ?? "") !== (b.query ?? "")) return false;
return true;
}
diff --git a/common/components/searchLayerCache.ts b/common/components/searchLayerCache.ts
@@ -0,0 +1,308 @@
+"use client";
+
+// Memoization layer for the composite-search tree (searchEval.ts).
+//
+// Each node's evaluation is keyed by `${canonicalHash(node)}__${scopeHash}`
+// — same node + same input scope = same result, regardless of where in the
+// tree it sits. This is what lets the user edit a deeply-nested leaf and
+// have unchanged sibling subtrees served instantly from cache.
+//
+// Backed by an in-memory Map (sync, fast path) plus an IndexedDB object
+// store (persistent across reloads, LRU-evicted when it grows too large).
+// IndexedDB writes are batched via queueMicrotask the same way as
+// transcriptStore.ts.
+
+import type { LayerHit } from "./searchPipeline";
+
+// Separate DB from `transcriptStore.ts` (`yt-dlp-transcript-browser`) so the
+// two stores can evolve their schemas independently — bumping the version
+// here doesn't force a coordinated upgrade in transcriptStore.
+const DB_NAME = "yt-dlp-transcript-browser:search-layer-cache";
+const DB_VERSION = 1;
+const STORE = "layers";
+const MAX_ENTRIES = 500;
+
+export type CachedResult = {
+ slugs: ReadonlySet<string>;
+ hits: ReadonlyMap<string, LayerHit[]>;
+};
+
+type StoredEntry = {
+ key: string;
+ slugs: string[];
+ hits: Record<string, LayerHit[]>;
+ ts: number;
+};
+
+type Mode = "pending" | "ok" | "unavailable";
+
+let mode: Mode = "pending";
+let dbPromise: Promise<IDBDatabase | null> | null = null;
+
+function openDb(): Promise<IDBDatabase | null> {
+ if (typeof indexedDB === "undefined") {
+ mode = "unavailable";
+ return Promise.resolve(null);
+ }
+ if (dbPromise) return dbPromise;
+ dbPromise = new Promise<IDBDatabase | null>((resolve) => {
+ let req: IDBOpenDBRequest;
+ try {
+ req = indexedDB.open(DB_NAME, DB_VERSION);
+ } catch {
+ downgrade("open threw");
+ resolve(null);
+ return;
+ }
+ req.onupgradeneeded = () => {
+ const db = req.result;
+ if (!db.objectStoreNames.contains(STORE)) {
+ const s = db.createObjectStore(STORE, { keyPath: "key" });
+ s.createIndex("ts", "ts", { unique: false });
+ }
+ };
+ req.onsuccess = () => {
+ mode = "ok";
+ resolve(req.result);
+ };
+ req.onerror = () => {
+ downgrade("open failed");
+ resolve(null);
+ };
+ req.onblocked = () => {
+ downgrade("open blocked");
+ resolve(null);
+ };
+ });
+ return dbPromise;
+}
+
+function downgrade(reason: string): void {
+ if (mode === "unavailable") return;
+ mode = "unavailable";
+ console.warn(
+ `[searchLayerCache] IndexedDB unavailable (${reason}); falling back to memory-only.`,
+ );
+}
+
+const memory = new Map<string, CachedResult>();
+
+export function cacheKey(nodeHash: string, scopeHash: string): string {
+ return `${nodeHash}__${scopeHash}`;
+}
+
+export function getCachedSync(key: string): CachedResult | null {
+ return memory.get(key) ?? null;
+}
+
+export async function getCached(key: string): Promise<CachedResult | null> {
+ const hit = memory.get(key);
+ if (hit) return hit;
+ const stored = await idbGet(key);
+ if (!stored) return null;
+ const result: CachedResult = {
+ slugs: new Set(stored.slugs),
+ hits: hitMapFromRecord(stored.hits),
+ };
+ memory.set(key, result);
+ // Touch ts so frequently-read entries float to the top of the LRU.
+ idbTouch(key).catch(() => {
+ /* swallow — touch is best-effort */
+ });
+ return result;
+}
+
+export function putCached(key: string, result: CachedResult): void {
+ memory.set(key, result);
+ const entry: StoredEntry = {
+ key,
+ slugs: Array.from(result.slugs),
+ hits: hitRecordFromMap(result.hits),
+ ts: Date.now(),
+ };
+ schedulePut(entry);
+}
+
+export function invalidateAll(): void {
+ memory.clear();
+ idbClearAll().catch(() => {
+ /* ignore */
+ });
+}
+
+// ─── Internal helpers ───
+
+function hitMapFromRecord(
+ record: Record<string, LayerHit[]>,
+): Map<string, LayerHit[]> {
+ const m = new Map<string, LayerHit[]>();
+ for (const [slug, hits] of Object.entries(record)) m.set(slug, hits);
+ return m;
+}
+
+function hitRecordFromMap(
+ map: ReadonlyMap<string, LayerHit[]>,
+): Record<string, LayerHit[]> {
+ const out: Record<string, LayerHit[]> = {};
+ for (const [slug, hits] of map) out[slug] = hits;
+ return out;
+}
+
+async function idbGet(key: string): Promise<StoredEntry | null> {
+ const db = await openDb();
+ if (!db) return null;
+ return new Promise<StoredEntry | null>((resolve) => {
+ let req: IDBRequest<StoredEntry | undefined>;
+ try {
+ const tx = db.transaction(STORE, "readonly");
+ req = tx.objectStore(STORE).get(key) as IDBRequest<
+ StoredEntry | undefined
+ >;
+ } catch {
+ downgrade("read tx threw");
+ resolve(null);
+ return;
+ }
+ req.onsuccess = () => resolve(req.result ?? null);
+ req.onerror = () => resolve(null);
+ });
+}
+
+async function idbTouch(key: string): Promise<void> {
+ const db = await openDb();
+ if (!db) return;
+ return new Promise<void>((resolve) => {
+ let tx: IDBTransaction;
+ try {
+ tx = db.transaction(STORE, "readwrite");
+ } catch {
+ downgrade("touch tx threw");
+ resolve();
+ return;
+ }
+ const store = tx.objectStore(STORE);
+ const getReq = store.get(key) as IDBRequest<StoredEntry | undefined>;
+ getReq.onsuccess = () => {
+ const entry = getReq.result;
+ if (entry) {
+ entry.ts = Date.now();
+ try {
+ store.put(entry);
+ } catch {
+ /* ignore */
+ }
+ }
+ };
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => resolve();
+ tx.onabort = () => resolve();
+ });
+}
+
+let pending: StoredEntry[] = [];
+let flushScheduled = false;
+
+function schedulePut(entry: StoredEntry): void {
+ if (mode === "unavailable") return;
+ // De-dupe consecutive writes to the same key — keep only the latest.
+ for (let i = 0; i < pending.length; i++) {
+ if (pending[i].key === entry.key) {
+ pending[i] = entry;
+ if (!flushScheduled) {
+ flushScheduled = true;
+ queueMicrotask(flushPending);
+ }
+ return;
+ }
+ }
+ pending.push(entry);
+ if (flushScheduled) return;
+ flushScheduled = true;
+ queueMicrotask(flushPending);
+}
+
+async function flushPending(): Promise<void> {
+ flushScheduled = false;
+ if (pending.length === 0) return;
+ const batch = pending;
+ pending = [];
+ const db = await openDb();
+ if (!db) return;
+ try {
+ const tx = db.transaction(STORE, "readwrite");
+ const store = tx.objectStore(STORE);
+ for (const e of batch) {
+ try {
+ store.put(e);
+ } catch {
+ /* per-entry failure (e.g. unclonable) shouldn't kill the batch */
+ }
+ }
+ await new Promise<void>((resolve) => {
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => resolve();
+ tx.onabort = () => resolve();
+ });
+ } catch {
+ downgrade("write tx threw");
+ return;
+ }
+ // LRU prune. Run opportunistically; missing this round is harmless.
+ pruneIfNeeded().catch(() => {
+ /* ignore */
+ });
+}
+
+async function pruneIfNeeded(): Promise<void> {
+ const db = await openDb();
+ if (!db) return;
+ try {
+ const countTx = db.transaction(STORE, "readonly");
+ const count = await new Promise<number>((resolve) => {
+ const req = countTx.objectStore(STORE).count();
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => resolve(0);
+ });
+ if (count <= MAX_ENTRIES) return;
+ const toRemove = count - MAX_ENTRIES;
+ const writeTx = db.transaction(STORE, "readwrite");
+ const idx = writeTx.objectStore(STORE).index("ts");
+ let removed = 0;
+ await new Promise<void>((resolve) => {
+ const cursorReq = idx.openCursor();
+ cursorReq.onsuccess = () => {
+ const c = cursorReq.result;
+ if (!c || removed >= toRemove) {
+ resolve();
+ return;
+ }
+ try {
+ c.delete();
+ } catch {
+ /* ignore */
+ }
+ removed++;
+ c.continue();
+ };
+ cursorReq.onerror = () => resolve();
+ });
+ } catch {
+ /* ignore — eviction is best-effort */
+ }
+}
+
+async function idbClearAll(): Promise<void> {
+ const db = await openDb();
+ if (!db) return;
+ return new Promise<void>((resolve) => {
+ try {
+ const tx = db.transaction(STORE, "readwrite");
+ tx.objectStore(STORE).clear();
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => resolve();
+ tx.onabort = () => resolve();
+ } catch {
+ resolve();
+ }
+ });
+}
diff --git a/common/components/searchPipeline.ts b/common/components/searchPipeline.ts
@@ -1,11 +1,23 @@
import { fetchTranscript } from "./transcriptCache";
import { fetchSubs } from "./subsCache";
import type { DisplaySummary } from "../lib/transcripts";
+import type { LayerScope, LeafNode } from "../lib/searchQuery";
export type Hit = { start: number; text: string };
export type SubsHit = { track: string; start: number; text: string };
+// Per-leaf hit shape consumed by the composite-search tree (searchEval.ts).
+// Carries enough info for the result-list UI to render the hit with its
+// originating layer's swatch + scope-specific decorations.
+export type LayerHit = {
+ leafId: string;
+ scope: LayerScope;
+ track?: string;
+ start: number;
+ text: string;
+};
+
export type PipelineUpdate = {
hitsBySlug: Record<string, Hit[]>;
totalHits: number;
@@ -269,6 +281,11 @@ type SubsPipelineConfig = {
regex: RegExp | null;
// Tracks to exclude. Empty set means "search all tracks".
excludedTracks: Set<string>;
+ // Optional inclusion filter. When provided, ONLY tracks in this set are
+ // scanned (and `excludedTracks` is irrelevant). Used by the composite-
+ // search "chat" scope to limit scanning to live_chat regardless of which
+ // other tracks a video has.
+ includedTracks?: Set<string> | null;
initialHitLimit: number;
concurrency: number;
flushIntervalMs: number;
@@ -284,6 +301,7 @@ export function createSubsSearchPipeline(
useRegex,
regex,
excludedTracks,
+ includedTracks,
initialHitLimit,
concurrency,
flushIntervalMs,
@@ -345,7 +363,9 @@ export function createSubsSearchPipeline(
if (totalSoFar < hitLimit) {
const slugHits: SubsHit[] = [];
for (const [track, cueList] of Object.entries(detail.tracks)) {
- if (excludedTracks.has(track)) continue;
+ if (includedTracks) {
+ if (!includedTracks.has(track)) continue;
+ } else if (excludedTracks.has(track)) continue;
if (totalSoFar + slugHits.length >= hitLimit) break;
const remaining =
hitLimit - totalSoFar - slugHits.length;
@@ -414,3 +434,237 @@ export function createSubsSearchPipeline(
},
};
}
+
+// ─── Leaf pipeline wrapper ───
+// Thin adapter over `createSearchPipeline` / `createSubsSearchPipeline` for
+// the composite-search tree (`searchEval.ts`). One controller per leaf in the
+// tree. Returns a `LeafController` that the tree orchestrator can cancel /
+// resize, plus a `done` promise that resolves with the final LeafResult.
+//
+// Scope=metadata isn't handled here — searchEval.ts evaluates it synchronously
+// over the summaries cache. This wrapper only deals with the network-backed
+// scopes (transcripts, chat) where the existing worker pool earns its keep.
+
+export type LeafProgress = {
+ slugs: Set<string>;
+ hits: Map<string, LayerHit[]>;
+ totalHits: number;
+ processed: number;
+ totalToProcess: number;
+ capped: boolean;
+ done: boolean;
+};
+
+export type LeafResult = {
+ slugs: Set<string>;
+ hits: Map<string, LayerHit[]>;
+};
+
+export type LeafController = PipelineController & {
+ done: Promise<LeafResult>;
+};
+
+export function runLeafPipeline(opts: {
+ leaf: LeafNode;
+ scopeSlugs: string[];
+ initialHitLimit: number;
+ concurrency: number;
+ flushIntervalMs: number;
+ emit: (p: LeafProgress) => void;
+}): LeafController {
+ const {
+ leaf,
+ scopeSlugs,
+ initialHitLimit,
+ concurrency,
+ flushIntervalMs,
+ emit,
+ } = opts;
+
+ const trimmed = leaf.query.trim();
+ // Caller guarantees query is non-empty before invoking us (see
+ // isLeafActive). Stay defensive: empty queries produce an immediate done
+ // with no hits.
+ if (!trimmed) {
+ const empty: LeafResult = { slugs: new Set(), hits: new Map() };
+ queueMicrotask(() => {
+ emit({
+ slugs: empty.slugs,
+ hits: empty.hits,
+ totalHits: 0,
+ processed: 0,
+ totalToProcess: 0,
+ capped: false,
+ done: true,
+ });
+ });
+ return {
+ cancel() {
+ /* no-op */
+ },
+ setHitLimit() {
+ /* no-op */
+ },
+ done: Promise.resolve(empty),
+ };
+ }
+
+ const regex = compileLeafRegex(leaf);
+
+ let resolveDone!: (r: LeafResult) => void;
+ const donePromise = new Promise<LeafResult>((resolve) => {
+ resolveDone = resolve;
+ });
+
+ // Latest seen progress, kept locally so `done` settles with the final
+ // payload that the consumer already saw on the last emit.
+ let finalSlugs = new Set<string>();
+ let finalHits = new Map<string, LayerHit[]>();
+ let settled = false;
+
+ const adaptTranscriptUpdate = (u: PipelineUpdate): LeafProgress => {
+ const slugs = new Set<string>();
+ const hits = new Map<string, LayerHit[]>();
+ for (const [slug, list] of Object.entries(u.hitsBySlug)) {
+ if (!list || list.length === 0) continue;
+ slugs.add(slug);
+ if (leaf.contributeHits) {
+ hits.set(
+ slug,
+ list.map((h) => ({
+ leafId: leaf.id,
+ scope: "transcripts" as const,
+ start: h.start,
+ text: h.text,
+ })),
+ );
+ }
+ }
+ return {
+ slugs,
+ hits,
+ totalHits: u.totalHits,
+ processed: u.processed,
+ totalToProcess: u.totalToProcess,
+ capped: u.capped,
+ done: u.done,
+ };
+ };
+
+ const adaptSubsUpdate = (u: SubsPipelineUpdate): LeafProgress => {
+ const slugs = new Set<string>();
+ const hits = new Map<string, LayerHit[]>();
+ for (const [slug, list] of Object.entries(u.hitsBySlug)) {
+ if (!list || list.length === 0) continue;
+ slugs.add(slug);
+ if (leaf.contributeHits) {
+ hits.set(
+ slug,
+ list.map((h) => ({
+ leafId: leaf.id,
+ scope: "chat" as const,
+ track: h.track,
+ start: h.start,
+ text: h.text,
+ })),
+ );
+ }
+ }
+ return {
+ slugs,
+ hits,
+ totalHits: u.totalHits,
+ processed: u.processed,
+ totalToProcess: u.totalToProcess,
+ capped: u.capped,
+ done: u.done,
+ };
+ };
+
+ const onProgress = (p: LeafProgress) => {
+ finalSlugs = p.slugs;
+ finalHits = p.hits;
+ emit(p);
+ if (p.done && !settled) {
+ settled = true;
+ resolveDone({ slugs: finalSlugs, hits: finalHits });
+ }
+ };
+
+ let controller: PipelineController;
+ if (leaf.scope === "transcripts") {
+ controller = createSearchPipeline({
+ slugs: scopeSlugs,
+ query: trimmed,
+ useRegex: leaf.useRegex,
+ regex,
+ initialHitLimit,
+ concurrency,
+ flushIntervalMs,
+ emit: (u) => onProgress(adaptTranscriptUpdate(u)),
+ });
+ } else if (leaf.scope === "chat") {
+ controller = createSubsSearchPipeline({
+ slugs: scopeSlugs,
+ query: trimmed,
+ useRegex: leaf.useRegex,
+ regex,
+ excludedTracks: EMPTY_TRACK_SET,
+ includedTracks: CHAT_ONLY_TRACK_SET,
+ initialHitLimit,
+ concurrency,
+ flushIntervalMs,
+ emit: (u) => onProgress(adaptSubsUpdate(u)),
+ });
+ } else {
+ // Metadata scope is handled by searchEval.ts directly. We shouldn't be
+ // invoked here; resolve immediately as a safety net.
+ const empty: LeafResult = { slugs: new Set(), hits: new Map() };
+ queueMicrotask(() => {
+ onProgress({
+ slugs: empty.slugs,
+ hits: empty.hits,
+ totalHits: 0,
+ processed: 0,
+ totalToProcess: 0,
+ capped: false,
+ done: true,
+ });
+ });
+ return {
+ cancel() {
+ /* no-op */
+ },
+ setHitLimit() {
+ /* no-op */
+ },
+ done: donePromise,
+ };
+ }
+
+ return {
+ cancel() {
+ controller.cancel();
+ if (!settled) {
+ settled = true;
+ resolveDone({ slugs: finalSlugs, hits: finalHits });
+ }
+ },
+ setHitLimit(limit: number) {
+ controller.setHitLimit(limit);
+ },
+ done: donePromise,
+ };
+}
+
+function compileLeafRegex(leaf: LeafNode): RegExp | null {
+ if (!leaf.useRegex) return null;
+ try {
+ return new RegExp(leaf.query, "i");
+ } catch {
+ return null;
+ }
+}
+
+const EMPTY_TRACK_SET: Set<string> = new Set();
+const CHAT_ONLY_TRACK_SET: Set<string> = new Set(["live_chat"]);
diff --git a/common/lib/searchEval.ts b/common/lib/searchEval.ts
@@ -0,0 +1,632 @@
+// Composite-search tree orchestrator.
+//
+// Walks a QueryNode tree (from `searchQuery.ts`), kicks off per-leaf
+// pipelines (from `searchPipeline.ts:runLeafPipeline`), runs metadata-scope
+// leaves synchronously against summaries, memoizes per-leaf results in
+// `searchLayerCache.ts`, and emits a streaming TreeProgress that the UI
+// renders.
+//
+// Evaluation rules (see plan):
+// AND group → narrow scope left-to-right between children
+// OR group → run children in parallel against the group's scope, union
+// NOT (flag) → child's result becomes `parent_scope \ child_result`
+// Empty / inactive subtrees act as identity (don't narrow)
+// The root is always a GroupNode (AND by default).
+
+import {
+ canonicalHash,
+ hashSlugs,
+ isGroup,
+ isLeaf,
+ isLeafActive,
+ isNodeActive,
+ type GroupNode,
+ type LeafNode,
+ type QueryNode,
+} from "./searchQuery";
+import {
+ cacheKey,
+ getCached,
+ getCachedSync,
+ putCached,
+ type CachedResult,
+} from "../components/searchLayerCache";
+import {
+ runLeafPipeline,
+ type LayerHit,
+ type LeafController,
+} from "../components/searchPipeline";
+import type { DisplaySummary } from "./transcripts";
+
+export type LeafState = {
+ slugCount: number;
+ totalHits: number;
+ processed: number;
+ totalToProcess: number;
+ capped: boolean;
+ cached: boolean;
+ // Whether the leaf pipeline is still actively producing results.
+ active: boolean;
+};
+
+export type GroupState = {
+ slugCount: number;
+};
+
+export type TreeProgress = {
+ slugs: ReadonlySet<string>;
+ // Per-video, ordered list of hits from contributing leaves. Stable
+ // identity per slug so React can reuse rendered hit rows; rebuilt on
+ // every progress emit since it's cheap.
+ hits: ReadonlyMap<string, LayerHit[]>;
+ leafStates: ReadonlyMap<string, LeafState>;
+ groupStates: ReadonlyMap<string, GroupState>;
+ done: boolean;
+ capped: boolean;
+};
+
+export type TreeController = {
+ cancel(): void;
+ setHitLimit(limit: number): void;
+};
+
+type MetadataLeafScope = "transcripts" | "chat" | "metadata";
+
+type EvalCtx = {
+ initialHitLimit: number;
+ concurrency: number;
+ flushIntervalMs: number;
+ metadataIndex: MetadataIndex;
+ // Slugs that have any subs (live_chat) content. Chat-scope leaves narrow
+ // their input scope by this set before running, so we don't waste fetches
+ // on videos that don't have a chat track at all.
+ chatScopeSlugs: ReadonlySet<string> | null;
+ // Per-leaf live result state. Reused for cache-hit instant emission and
+ // for incremental re-evaluation on each leaf progress.
+ leafResults: Map<string, { slugs: Set<string>; hits: Map<string, LayerHit[]> }>;
+ leafStates: Map<string, LeafState>;
+ controllers: Map<string, LeafController>;
+ cancelled: boolean;
+ hitLimit: number;
+ emitRoot: () => void;
+};
+
+// Pre-built metadata index from summaries. Built once per tree run and
+// reused across all metadata-scope leaves so re-evaluating a metadata leaf
+// edit is instant.
+type MetadataIndex = {
+ bySlug: Map<string, DisplaySummary>;
+ // Lower-cased title and channel name for fast case-insensitive contains.
+ titleLowerBySlug: Map<string, string>;
+ channelLowerBySlug: Map<string, string>;
+};
+
+function buildMetadataIndex(summaries: DisplaySummary[]): MetadataIndex {
+ const bySlug = new Map<string, DisplaySummary>();
+ const titleLowerBySlug = new Map<string, string>();
+ const channelLowerBySlug = new Map<string, string>();
+ for (const s of summaries) {
+ bySlug.set(s.slug, s);
+ titleLowerBySlug.set(s.slug, (s.title ?? "").toLowerCase());
+ channelLowerBySlug.set(s.slug, (s.channel ?? "").toLowerCase());
+ }
+ return { bySlug, titleLowerBySlug, channelLowerBySlug };
+}
+
+export function runQueryTree(opts: {
+ root: GroupNode;
+ globalScope: string[];
+ summaries: DisplaySummary[];
+ chatScopeSlugs?: ReadonlySet<string> | null;
+ initialHitLimit: number;
+ concurrency: number;
+ flushIntervalMs: number;
+ emit: (p: TreeProgress) => void;
+}): TreeController {
+ const {
+ root,
+ globalScope,
+ summaries,
+ chatScopeSlugs = null,
+ initialHitLimit,
+ concurrency,
+ flushIntervalMs,
+ emit,
+ } = opts;
+
+ const metadataIndex = buildMetadataIndex(summaries);
+
+ let cancelled = false;
+ let allDone = false;
+
+ const ctx: EvalCtx = {
+ initialHitLimit,
+ concurrency,
+ flushIntervalMs,
+ metadataIndex,
+ chatScopeSlugs,
+ leafResults: new Map(),
+ leafStates: new Map(),
+ controllers: new Map(),
+ cancelled: false,
+ hitLimit: initialHitLimit,
+ emitRoot: () => emitRoot(),
+ };
+
+ // Latest emission cache — used so React doesn't see identity churn on
+ // unchanged slug sets between progress events.
+ let lastEmitToken = "";
+
+ function emitRoot(): void {
+ if (cancelled) return;
+ const r = evaluateTree(root, new Set(globalScope), ctx);
+ const groupStates = new Map<string, GroupState>();
+ collectGroupStates(root, new Set(globalScope), ctx, groupStates);
+ const capped = anyLeafCapped(ctx);
+ const progress: TreeProgress = {
+ slugs: r.slugs,
+ hits: r.hits,
+ leafStates: ctx.leafStates,
+ groupStates,
+ done: allDone,
+ capped,
+ };
+ // Cheap signature to avoid duplicate emits when re-running root after
+ // a no-op cache touch.
+ const token = `${r.slugs.size}|${allDone ? 1 : 0}|${capped ? 1 : 0}|${ctx.leafStates.size}`;
+ if (token === lastEmitToken && !allDone) {
+ // still emit to refresh hits ordering; cheap, fine.
+ }
+ lastEmitToken = token;
+ emit(progress);
+ }
+
+ // Kick off all leaf evaluations on the next microtask so callers wiring
+ // listeners have a chance to subscribe before the first emit fires.
+ Promise.resolve().then(async () => {
+ if (cancelled) return;
+ try {
+ await orchestrate(root, new Set(globalScope), ctx);
+ } catch {
+ /* per-leaf errors already swallowed inside */
+ }
+ if (cancelled) return;
+ allDone = true;
+ emitRoot();
+ });
+
+ return {
+ cancel() {
+ cancelled = true;
+ ctx.cancelled = true;
+ for (const ctrl of ctx.controllers.values()) ctrl.cancel();
+ },
+ setHitLimit(limit: number) {
+ if (cancelled) return;
+ if (limit <= ctx.hitLimit) return;
+ ctx.hitLimit = limit;
+ for (const ctrl of ctx.controllers.values()) ctrl.setHitLimit(limit);
+ },
+ };
+}
+
+// Orchestrate evaluation: actually run leaf pipelines (or metadata sync) and
+// populate ctx.leafResults / leafStates. AND children run sequentially with
+// scope narrowed between them; OR children run in parallel.
+async function orchestrate(
+ node: QueryNode,
+ parentScope: Set<string>,
+ ctx: EvalCtx,
+): Promise<Set<string>> {
+ if (ctx.cancelled) return new Set();
+ if (!isNodeActive(node)) {
+ // Inactive subtree → pass-through. Don't narrow anything.
+ return parentScope;
+ }
+ if (isLeaf(node)) {
+ const slugs = await runLeaf(node, parentScope, ctx);
+ if (node.negate) return diff(parentScope, slugs);
+ return intersect(parentScope, slugs);
+ }
+ // Group
+ if (node.op === "AND") {
+ let scope = parentScope;
+ for (const child of node.children) {
+ if (ctx.cancelled) break;
+ if (!isNodeActive(child)) continue;
+ scope = await orchestrate(child, scope, ctx);
+ }
+ if (node.negate) return diff(parentScope, scope);
+ return scope;
+ }
+ // OR
+ const childSlugSets: Set<string>[] = [];
+ await Promise.all(
+ node.children.map(async (child) => {
+ if (ctx.cancelled) return;
+ if (!isNodeActive(child)) return;
+ const r = await orchestrate(child, parentScope, ctx);
+ childSlugSets.push(r);
+ }),
+ );
+ const unioned = unionAll(childSlugSets);
+ if (node.negate) return diff(parentScope, unioned);
+ return unioned;
+}
+
+// Run a single leaf against the given scope. Checks cache; on miss, runs the
+// appropriate scope-specific evaluator. Updates ctx.leafResults / leafStates
+// and pushes a root re-evaluation via ctx.emitRoot on every progress event.
+async function runLeaf(
+ leaf: LeafNode,
+ parentScope: Set<string>,
+ ctx: EvalCtx,
+): Promise<Set<string>> {
+ if (!isLeafActive(leaf)) {
+ // Inactive (empty) leaf — record empty result, treat as identity.
+ setLeafState(ctx, leaf.id, {
+ slugCount: 0,
+ totalHits: 0,
+ processed: 0,
+ totalToProcess: 0,
+ capped: false,
+ cached: false,
+ active: false,
+ });
+ ctx.leafResults.set(leaf.id, { slugs: new Set(), hits: new Map() });
+ return parentScope;
+ }
+
+ if (leaf.scope === "metadata") {
+ return runMetadataLeaf(leaf, parentScope, ctx);
+ }
+
+ // Pre-filter chat-scope leaves to slugs that actually have a live_chat
+ // track. Saves wasted fetches and makes the cache scope hash stable
+ // against changes that only affect non-chat-having channels.
+ let effectiveScope = parentScope;
+ if (leaf.scope === "chat" && ctx.chatScopeSlugs) {
+ effectiveScope = intersect(parentScope, ctx.chatScopeSlugs);
+ }
+
+ // Fully-network leaves (transcripts / chat). Try the layer cache first.
+ const scopeArr = Array.from(effectiveScope);
+ const scopeHash = hashSlugs(scopeArr);
+ const key = cacheKey(canonicalHash(leaf), scopeHash);
+ const cachedSync = getCachedSync(key);
+ if (cachedSync) {
+ return applyCached(leaf, parentScope, cachedSync, ctx, /*cached*/ true);
+ }
+ const cached = await getCached(key);
+ if (ctx.cancelled) return new Set();
+ if (cached) {
+ return applyCached(leaf, parentScope, cached, ctx, /*cached*/ true);
+ }
+
+ // Cache miss — kick off a real pipeline. Pure-filter leaves (no hit
+ // contribution) get an unbounded cap so a downstream AND chain can
+ // continue to narrow without partial-scope artifacts.
+ const initialHitLimit = leaf.contributeHits ? ctx.hitLimit : Infinity;
+
+ setLeafState(ctx, leaf.id, {
+ slugCount: 0,
+ totalHits: 0,
+ processed: 0,
+ totalToProcess: scopeArr.length,
+ capped: false,
+ cached: false,
+ active: true,
+ });
+
+ let lastCapped = false;
+ const controller = runLeafPipeline({
+ leaf,
+ scopeSlugs: scopeArr,
+ initialHitLimit,
+ concurrency: ctx.concurrency,
+ flushIntervalMs: ctx.flushIntervalMs,
+ emit: (p) => {
+ if (ctx.cancelled) return;
+ ctx.leafResults.set(leaf.id, {
+ slugs: new Set(p.slugs),
+ hits: new Map(p.hits),
+ });
+ setLeafState(ctx, leaf.id, {
+ slugCount: p.slugs.size,
+ totalHits: p.totalHits,
+ processed: p.processed,
+ totalToProcess: p.totalToProcess,
+ capped: p.capped,
+ cached: false,
+ active: !p.done,
+ });
+ lastCapped = p.capped;
+ ctx.emitRoot();
+ },
+ });
+ ctx.controllers.set(leaf.id, controller);
+ const result = await controller.done;
+ ctx.controllers.delete(leaf.id);
+ if (ctx.cancelled) return new Set();
+ // Persist final result only on a natural completion (not cap-limited and
+ // not cancelled). A capped result is partial — caching it would prevent a
+ // later "show more" from extending into the rest of the scope.
+ if (!lastCapped) {
+ putCached(key, {
+ slugs: new Set(result.slugs),
+ hits: new Map(result.hits),
+ });
+ }
+ return new Set(result.slugs);
+}
+
+function applyCached(
+ leaf: LeafNode,
+ parentScope: Set<string>,
+ cached: CachedResult,
+ ctx: EvalCtx,
+ isCacheHit: boolean,
+): Set<string> {
+ const slugs = new Set<string>();
+ for (const s of cached.slugs) slugs.add(s);
+ const hits = new Map<string, LayerHit[]>();
+ for (const [slug, h] of cached.hits) hits.set(slug, h);
+ ctx.leafResults.set(leaf.id, { slugs, hits });
+ setLeafState(ctx, leaf.id, {
+ slugCount: slugs.size,
+ totalHits: countHits(hits),
+ processed: parentScope.size,
+ totalToProcess: parentScope.size,
+ capped: false,
+ cached: isCacheHit,
+ active: false,
+ });
+ ctx.emitRoot();
+ return slugs;
+}
+
+function runMetadataLeaf(
+ leaf: LeafNode,
+ parentScope: Set<string>,
+ ctx: EvalCtx,
+): Set<string> {
+ const matched = new Set<string>();
+ const hits = new Map<string, LayerHit[]>();
+ const q = leaf.query.trim();
+ if (!q) {
+ ctx.leafResults.set(leaf.id, { slugs: matched, hits });
+ setLeafState(ctx, leaf.id, {
+ slugCount: 0,
+ totalHits: 0,
+ processed: parentScope.size,
+ totalToProcess: parentScope.size,
+ capped: false,
+ cached: false,
+ active: false,
+ });
+ return parentScope;
+ }
+ let re: RegExp | null = null;
+ if (leaf.useRegex) {
+ try {
+ re = new RegExp(q, "i");
+ } catch {
+ re = null;
+ }
+ if (!re) {
+ ctx.leafResults.set(leaf.id, { slugs: matched, hits });
+ setLeafState(ctx, leaf.id, {
+ slugCount: 0,
+ totalHits: 0,
+ processed: parentScope.size,
+ totalToProcess: parentScope.size,
+ capped: false,
+ cached: false,
+ active: false,
+ });
+ return new Set();
+ }
+ }
+ const lower = re ? "" : q.toLowerCase();
+ for (const slug of parentScope) {
+ const title = ctx.metadataIndex.titleLowerBySlug.get(slug);
+ const channel = ctx.metadataIndex.channelLowerBySlug.get(slug);
+ if (title === undefined && channel === undefined) continue;
+ const titleHit = re
+ ? title !== undefined && re.test(title)
+ : title !== undefined && title.includes(lower);
+ const channelHit = re
+ ? channel !== undefined && re.test(channel)
+ : channel !== undefined && channel.includes(lower);
+ if (!titleHit && !channelHit) continue;
+ matched.add(slug);
+ if (leaf.contributeHits) {
+ const summary = ctx.metadataIndex.bySlug.get(slug);
+ const out: LayerHit[] = [];
+ if (titleHit && summary) {
+ out.push({
+ leafId: leaf.id,
+ scope: "metadata",
+ start: 0,
+ text: summary.title,
+ });
+ }
+ if (channelHit && !titleHit && summary) {
+ // Channel-only hits don't have a meaningful timestamp; surface them
+ // with start=0 too. Result rendering treats start=0 as "no jump".
+ out.push({
+ leafId: leaf.id,
+ scope: "metadata",
+ start: 0,
+ text: `Channel: ${summary.channel}`,
+ });
+ }
+ if (out.length > 0) hits.set(slug, out);
+ }
+ }
+ ctx.leafResults.set(leaf.id, { slugs: matched, hits });
+ setLeafState(ctx, leaf.id, {
+ slugCount: matched.size,
+ totalHits: countHits(hits),
+ processed: parentScope.size,
+ totalToProcess: parentScope.size,
+ capped: false,
+ cached: false,
+ active: false,
+ });
+ ctx.emitRoot();
+ // Negate handled by the caller in `orchestrate` — return raw matches.
+ return matched;
+}
+
+// ─── Pure-tree re-evaluation ───
+// Computes the current root slug set + hit map from ctx.leafResults. Called
+// on every leaf progress so the UI sees streaming narrowing. This is the
+// "what does the tree currently produce" view, separate from the
+// orchestration that actually fills leafResults.
+
+function evaluateTree(
+ node: QueryNode,
+ parentScope: Set<string>,
+ ctx: EvalCtx,
+): { slugs: Set<string>; hits: Map<string, LayerHit[]> } {
+ const hits = new Map<string, LayerHit[]>();
+ const slugs = evaluateSlugs(node, parentScope, ctx, hits);
+ // Filter hits to surviving slugs, so a leaf earlier in an AND chain
+ // doesn't leak hits for videos that a later child has since excluded.
+ for (const slug of Array.from(hits.keys())) {
+ if (!slugs.has(slug)) hits.delete(slug);
+ }
+ // Sort each video's hits by start time so transcripts + chat interleave
+ // chronologically across layers.
+ for (const list of hits.values()) list.sort((a, b) => a.start - b.start);
+ return { slugs, hits };
+}
+
+function evaluateSlugs(
+ node: QueryNode,
+ parentScope: Set<string>,
+ ctx: EvalCtx,
+ hitSink: Map<string, LayerHit[]>,
+): Set<string> {
+ if (!isNodeActive(node)) return parentScope;
+ if (isLeaf(node)) {
+ const r = ctx.leafResults.get(node.id);
+ if (!r) {
+ // Pipeline hasn't started yet — be permissive so AND results don't
+ // briefly collapse to empty before the leaf has reported.
+ return parentScope;
+ }
+ if (node.contributeHits) {
+ for (const [slug, list] of r.hits) {
+ const existing = hitSink.get(slug);
+ if (existing) existing.push(...list);
+ else hitSink.set(slug, list.slice());
+ }
+ }
+ if (node.negate) return diff(parentScope, r.slugs);
+ return intersect(parentScope, r.slugs);
+ }
+ // Group
+ if (node.op === "AND") {
+ let scope = parentScope;
+ for (const child of node.children) {
+ if (!isNodeActive(child)) continue;
+ scope = evaluateSlugs(child, scope, ctx, hitSink);
+ }
+ if (node.negate) return diff(parentScope, scope);
+ return scope;
+ }
+ // OR
+ const acc = new Set<string>();
+ for (const child of node.children) {
+ if (!isNodeActive(child)) continue;
+ const r = evaluateSlugs(child, parentScope, ctx, hitSink);
+ for (const s of r) acc.add(s);
+ }
+ if (node.negate) return diff(parentScope, acc);
+ return acc;
+}
+
+function collectGroupStates(
+ node: QueryNode,
+ parentScope: Set<string>,
+ ctx: EvalCtx,
+ out: Map<string, GroupState>,
+): Set<string> {
+ if (!isNodeActive(node)) {
+ if (isGroup(node)) out.set(node.id, { slugCount: parentScope.size });
+ return parentScope;
+ }
+ if (isLeaf(node)) {
+ const r = ctx.leafResults.get(node.id);
+ if (!r) return parentScope;
+ if (node.negate) return diff(parentScope, r.slugs);
+ return intersect(parentScope, r.slugs);
+ }
+ let scopeOut: Set<string>;
+ if (node.op === "AND") {
+ let scope = parentScope;
+ for (const child of node.children) {
+ if (!isNodeActive(child)) continue;
+ scope = collectGroupStates(child, scope, ctx, out);
+ }
+ scopeOut = scope;
+ } else {
+ const acc = new Set<string>();
+ for (const child of node.children) {
+ if (!isNodeActive(child)) continue;
+ const r = collectGroupStates(child, parentScope, ctx, out);
+ for (const s of r) acc.add(s);
+ }
+ scopeOut = acc;
+ }
+ const finalScope = node.negate ? diff(parentScope, scopeOut) : scopeOut;
+ out.set(node.id, { slugCount: finalScope.size });
+ return finalScope;
+}
+
+function anyLeafCapped(ctx: EvalCtx): boolean {
+ for (const s of ctx.leafStates.values()) if (s.capped) return true;
+ return false;
+}
+
+function setLeafState(
+ ctx: EvalCtx,
+ id: string,
+ state: LeafState,
+): void {
+ ctx.leafStates.set(id, state);
+}
+
+function countHits(hits: ReadonlyMap<string, LayerHit[]>): number {
+ let n = 0;
+ for (const list of hits.values()) n += list.length;
+ return n;
+}
+
+function intersect(a: Set<string>, b: ReadonlySet<string>): Set<string> {
+ if (a.size === 0 || b.size === 0) return new Set();
+ // Iterate the smaller set for cheaper membership lookups.
+ const [small, large]: [ReadonlySet<string>, ReadonlySet<string>] =
+ a.size <= b.size ? [a, b] : [b, a];
+ const out = new Set<string>();
+ for (const s of small) if (large.has(s)) out.add(s);
+ return out;
+}
+
+function diff(a: Set<string>, b: ReadonlySet<string>): Set<string> {
+ const out = new Set<string>();
+ for (const s of a) if (!b.has(s)) out.add(s);
+ return out;
+}
+
+function unionAll(sets: Set<string>[]): Set<string> {
+ if (sets.length === 1) return new Set(sets[0]);
+ const out = new Set<string>();
+ for (const s of sets) for (const v of s) out.add(v);
+ return out;
+}
+
+// Re-export the leaf-scope type for UI consumers.
+export type { MetadataLeafScope };
diff --git a/common/lib/searchQuery.ts b/common/lib/searchQuery.ts
@@ -0,0 +1,383 @@
+// Composite-search query tree.
+//
+// The tree is the user's full search expression: a root GroupNode (AND by
+// default) containing LeafNodes (single search terms) and nested GroupNodes
+// (sub-expressions). The eval engine in `searchEval.ts` walks this tree and
+// returns the matching slug set + per-leaf hits.
+//
+// Serialization here is the source of truth for both the URL (`qt=` param)
+// and IndexedDB-backed layer cache keys. Keep field names short — they end
+// up in URLs.
+
+import type { SearchMode } from "../components/urlState";
+
+export type LayerScope = "transcripts" | "chat" | "metadata";
+
+export type GroupOp = "AND" | "OR";
+
+export type LeafNode = {
+ kind: "leaf";
+ id: string;
+ query: string;
+ scope: LayerScope;
+ useRegex: boolean;
+ contributeHits: boolean;
+ negate: boolean;
+};
+
+export type GroupNode = {
+ kind: "group";
+ id: string;
+ op: GroupOp;
+ negate: boolean;
+ children: QueryNode[];
+};
+
+export type QueryNode = LeafNode | GroupNode;
+
+let _idCounter = 0;
+// IDs intentionally exclude Date.now() / Math.random() so that the same
+// `emptyRoot()` call on the server and client produces matching IDs — Next's
+// hydration warning fires loudly on any data-* attribute mismatch.
+function nextId(prefix: string): string {
+ _idCounter = (_idCounter + 1) | 0;
+ return `${prefix}${_idCounter.toString(36)}`;
+}
+
+export function newLeaf(partial: Partial<LeafNode> = {}): LeafNode {
+ return {
+ kind: "leaf",
+ id: partial.id ?? nextId("l"),
+ query: partial.query ?? "",
+ scope: partial.scope ?? "transcripts",
+ useRegex: partial.useRegex ?? false,
+ contributeHits: partial.contributeHits ?? true,
+ negate: partial.negate ?? false,
+ };
+}
+
+export function newGroup(partial: Partial<GroupNode> = {}): GroupNode {
+ return {
+ kind: "group",
+ id: partial.id ?? nextId("g"),
+ op: partial.op ?? "AND",
+ negate: partial.negate ?? false,
+ children: partial.children ?? [],
+ };
+}
+
+export function emptyRoot(): GroupNode {
+ return newGroup({ children: [newLeaf({ scope: "transcripts" })] });
+}
+
+export function isLeaf(n: QueryNode): n is LeafNode {
+ return n.kind === "leaf";
+}
+
+export function isGroup(n: QueryNode): n is GroupNode {
+ return n.kind === "group";
+}
+
+// A leaf is "active" (has any effect on evaluation) only when it has a
+// non-empty query. Empty leaves are skipped at eval time rather than treated
+// as a wildcard match — see searchEval.ts.
+export function isLeafActive(leaf: LeafNode): boolean {
+ return leaf.query.trim().length > 0;
+}
+
+// Recursively determine whether a node contributes anything to evaluation.
+// Used to short-circuit empty subtrees so a freshly added blank leaf doesn't
+// nuke an AND result down to zero.
+export function isNodeActive(n: QueryNode): boolean {
+ if (isLeaf(n)) return isLeafActive(n);
+ return n.children.some(isNodeActive);
+}
+
+// ─── Canonical hash ───
+// Stable hash over content (not IDs). Used as the prefix of the layer-cache
+// key so semantically equivalent trees collide in cache regardless of the
+// random IDs assigned to their nodes. Children of OR groups are sorted by
+// their own hash; AND children stay ordered for evaluation purposes but the
+// cache only consumes the final hash so order doesn't matter for hits.
+
+function fnv1a(str: string): string {
+ // 32-bit FNV-1a. Sufficient for cache keys (collision risk negligible at
+ // the cardinalities this UI produces) and avoids pulling in a crypto dep.
+ let h = 0x811c9dc5;
+ for (let i = 0; i < str.length; i++) {
+ h ^= str.charCodeAt(i);
+ h = Math.imul(h, 0x01000193);
+ }
+ return (h >>> 0).toString(36);
+}
+
+export function canonicalHash(n: QueryNode): string {
+ return fnv1a(canonicalString(n));
+}
+
+function canonicalString(n: QueryNode): string {
+ if (isLeaf(n)) {
+ const q = n.useRegex ? n.query : n.query.toLowerCase();
+ return `L|${n.scope}|${n.useRegex ? "r" : "p"}|${n.negate ? "n" : "y"}|${q}`;
+ }
+ const childStrs = n.children.filter(isNodeActive).map(canonicalString);
+ if (n.op === "OR") childStrs.sort();
+ return `G|${n.op}|${n.negate ? "n" : "y"}|${childStrs.join("")}`;
+}
+
+export function hashSlugs(slugs: ReadonlyArray<string> | ReadonlySet<string>): string {
+ const arr = Array.isArray(slugs) ? slugs.slice() : Array.from(slugs);
+ arr.sort();
+ return fnv1a(arr.join("|"));
+}
+
+// ─── Serialization (URL/storage) ───
+// Compact JSON shape: { o, c, n } for groups, { k:"l", q, s, r, h, n, i } for
+// leaves. IDs are preserved so React keys stay stable across reloads.
+
+type SerializedLeaf = {
+ k: "l";
+ i?: string;
+ q: string;
+ s: LayerScope;
+ r?: 1;
+ h?: 0;
+ n?: 1;
+};
+type SerializedGroup = {
+ k: "g";
+ i?: string;
+ o: GroupOp;
+ n?: 1;
+ c: Serialized[];
+};
+type Serialized = SerializedLeaf | SerializedGroup;
+
+function serialize(n: QueryNode): Serialized {
+ // IDs are intentionally NOT serialized — they're session-scoped (used as
+ // React keys + for the swatch colour hash). Persisting them would just
+ // produce noisy URLs without enabling anything useful, and leaving them
+ // out keeps the URL stable across sessions for the same logical query.
+ if (isLeaf(n)) {
+ const out: SerializedLeaf = { k: "l", q: n.query, s: n.scope };
+ if (n.useRegex) out.r = 1;
+ if (!n.contributeHits) out.h = 0;
+ if (n.negate) out.n = 1;
+ return out;
+ }
+ const out: SerializedGroup = {
+ k: "g",
+ o: n.op,
+ c: n.children.map(serialize),
+ };
+ if (n.negate) out.n = 1;
+ return out;
+}
+
+function deserialize(raw: unknown): QueryNode | null {
+ if (!raw || typeof raw !== "object") return null;
+ const r = raw as Record<string, unknown>;
+ if (r.k === "l") {
+ const scope = r.s;
+ if (scope !== "transcripts" && scope !== "chat" && scope !== "metadata") {
+ return null;
+ }
+ return {
+ kind: "leaf",
+ id: typeof r.i === "string" && r.i ? r.i : nextId("l"),
+ query: typeof r.q === "string" ? r.q : "",
+ scope,
+ useRegex: r.r === 1,
+ contributeHits: r.h !== 0,
+ negate: r.n === 1,
+ };
+ }
+ if (r.k === "g") {
+ const op = r.o;
+ if (op !== "AND" && op !== "OR") return null;
+ const rawChildren = Array.isArray(r.c) ? r.c : [];
+ const children: QueryNode[] = [];
+ for (const c of rawChildren) {
+ const child = deserialize(c);
+ if (child) children.push(child);
+ }
+ return {
+ kind: "group",
+ id: typeof r.i === "string" && r.i ? r.i : nextId("g"),
+ op,
+ negate: r.n === 1,
+ children,
+ };
+ }
+ return null;
+}
+
+export function stringifyRoot(root: GroupNode): string {
+ return JSON.stringify(serialize(root));
+}
+
+export function parseRoot(s: string): GroupNode | null {
+ let raw: unknown;
+ try {
+ raw = JSON.parse(s);
+ } catch {
+ return null;
+ }
+ const node = deserialize(raw);
+ if (!node || !isGroup(node)) return null;
+ return node;
+}
+
+// Build a root from the legacy single-input search (`?q=` / `?m=` / `?re=`).
+// Returns a one-leaf root targeted at the appropriate scope. Used when a URL
+// has `q=` but no `qt=`, so old share links keep working.
+export function rootFromLegacy(
+ q: string,
+ mode: SearchMode,
+ useRegex: boolean,
+): GroupNode {
+ return newGroup({
+ children: [
+ newLeaf({
+ query: q,
+ scope: mode === "subs" ? "chat" : "transcripts",
+ useRegex,
+ contributeHits: true,
+ }),
+ ],
+ });
+}
+
+// ─── Tree mutation helpers ───
+// All mutations are immutable: each helper returns a new root with the
+// changed subtree replaced. Easier reasoning for React and matches the
+// "edit-only-the-leaf" cache-reuse story (sibling subtrees keep identity).
+
+export function replaceNode(
+ root: GroupNode,
+ id: string,
+ next: QueryNode,
+): GroupNode {
+ const out = replaceInNode(root, id, next);
+ if (!out || !isGroup(out)) return root;
+ return out;
+}
+
+function replaceInNode(
+ node: QueryNode,
+ id: string,
+ next: QueryNode,
+): QueryNode | null {
+ if (node.id === id) return next;
+ if (!isGroup(node)) return null;
+ let changed = false;
+ const newChildren: QueryNode[] = node.children.map((c) => {
+ const r = replaceInNode(c, id, next);
+ if (r) {
+ changed = true;
+ return r;
+ }
+ return c;
+ });
+ if (!changed) return null;
+ return { ...node, children: newChildren };
+}
+
+export function removeNode(root: GroupNode, id: string): GroupNode {
+ const out = removeFromNode(root, id);
+ if (!out || !isGroup(out)) return root;
+ return out;
+}
+
+function removeFromNode(node: QueryNode, id: string): QueryNode | null {
+ if (!isGroup(node)) return null;
+ let changed = false;
+ const newChildren: QueryNode[] = [];
+ for (const c of node.children) {
+ if (c.id === id) {
+ changed = true;
+ continue;
+ }
+ const r = removeFromNode(c, id);
+ if (r) {
+ changed = true;
+ newChildren.push(r);
+ } else {
+ newChildren.push(c);
+ }
+ }
+ if (!changed) return null;
+ return { ...node, children: newChildren };
+}
+
+export function insertChild(
+ root: GroupNode,
+ groupId: string,
+ child: QueryNode,
+ position: "start" | "end" = "end",
+): GroupNode {
+ const out = insertInNode(root, groupId, child, position);
+ if (!out || !isGroup(out)) return root;
+ return out;
+}
+
+function insertInNode(
+ node: QueryNode,
+ groupId: string,
+ child: QueryNode,
+ position: "start" | "end",
+): QueryNode | null {
+ if (!isGroup(node)) return null;
+ if (node.id === groupId) {
+ const newChildren =
+ position === "start"
+ ? [child, ...node.children]
+ : [...node.children, child];
+ return { ...node, children: newChildren };
+ }
+ let changed = false;
+ const newChildren: QueryNode[] = node.children.map((c) => {
+ const r = insertInNode(c, groupId, child, position);
+ if (r) {
+ changed = true;
+ return r;
+ }
+ return c;
+ });
+ if (!changed) return null;
+ return { ...node, children: newChildren };
+}
+
+// "Wrap leaf in a group": replace the leaf with a fresh AND group containing
+// it. Used by the "Add OR sibling" UX so users don't have to manually create
+// a group + drag the leaf into it.
+export function wrapInGroup(
+ root: GroupNode,
+ id: string,
+ op: GroupOp = "AND",
+): GroupNode {
+ let target: QueryNode | null = null;
+ function find(n: QueryNode): void {
+ if (n.id === id) {
+ target = n;
+ return;
+ }
+ if (isGroup(n)) for (const c of n.children) find(c);
+ }
+ find(root);
+ if (!target) return root;
+ return replaceNode(root, id, newGroup({ op, children: [target] }));
+}
+
+// Walk every leaf in the tree, depth-first. Used by the UI to compute live
+// counts, colour swatches, and the "contributing leaves" list.
+export function forEachLeaf(
+ root: QueryNode,
+ fn: (leaf: LeafNode) => void,
+): void {
+ if (isLeaf(root)) {
+ fn(root);
+ return;
+ }
+ for (const c of root.children) forEachLeaf(c, fn);
+}
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -3,6 +3,7 @@
## [Unreleased]
### Added
+- Composable layered search (export viewer). The single search bar is now a query builder supporting AND / OR / NOT and arbitrary nesting; each layer targets transcripts, live chat, or title/channel. Per-layer results are memoised in IndexedDB so editing a deeper leaf only re-runs that layer against its already-narrowed scope. Old `?q=…&m=…&re=…` share-links auto-migrate to a single-leaf tree. See `export/CHANGELOG.md` for the user-visible details.
- New `/deploy` page that previews `export/CHANGELOG.md` pending changes, offers a "Cut release" form (turns `## [Unreleased]` into a dated semver heading) and a "Deploy" button (runs `pnpm run deploy` in `export/` with live log streaming).
- "Cut release" form at the bottom of the `/changelog` page for the editor's own changelog.
- New `/changelog` page that renders the editor's `CHANGELOG.md`. Linked from the sidebar nav.
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -3,5 +3,6 @@
## [Unreleased]
### Added
+- Composable layered search. The single search bar is now a query builder: any number of layers can be combined with AND / OR / NOT and arbitrary nesting. Each layer targets a scope (transcripts, live chat, or title/channel), and matches from every contributing layer are surfaced in the result list with a per-layer colour swatch. Per-layer results are memoised in IndexedDB so editing a deeper leaf only re-runs that layer against its already-narrowed scope. Simple one-keyword search still looks like a single input — the builder collapses to compact mode when there's only one layer. The composite query serialises into a new `qt=` URL parameter; legacy `?q=&m=&re=` links auto-migrate to a one-layer tree.
- New `/changelog` page that renders the export's `CHANGELOG.md`. Linked from the right side of the sticky header.
- Per-heading copy-link buttons on the changelog page for permalinks to any section.
diff --git a/export/e2e/query-tree.spec.ts b/export/e2e/query-tree.spec.ts
@@ -0,0 +1,260 @@
+import { expect, test, type Page } from "@playwright/test";
+import {
+ CHANNEL_SLUG,
+ VIDEO_CHAT_LARGE,
+ VIDEO_CHAT_SMALL,
+ VIDEO_TRANSCRIPT_ONLY,
+} from "./fixtures/data";
+import { installRoutes } from "./helpers";
+
+// Composite-search query tree e2e tests.
+//
+// Each test seeds the search via the `?qt=` URL parameter (compact JSON of
+// the QueryNode tree) so we don't have to script the builder UI on every
+// scenario. UI-interaction tests (cache-hit, URL round-trip from typing)
+// still drive the builder directly.
+
+type SLeaf = {
+ k: "l";
+ i?: string;
+ q: string;
+ s: "transcripts" | "chat" | "metadata";
+ r?: 1;
+ h?: 0;
+ n?: 1;
+};
+type SGroup = {
+ k: "g";
+ i?: string;
+ o: "AND" | "OR";
+ n?: 1;
+ c: (SLeaf | SGroup)[];
+};
+
+function qt(root: SGroup): string {
+ return encodeURIComponent(JSON.stringify(root));
+}
+
+const TRANSCRIPT_ONLY_SLUG = `${CHANNEL_SLUG}/${VIDEO_TRANSCRIPT_ONLY}`;
+const CHAT_SMALL_SLUG = `${CHANNEL_SLUG}/${VIDEO_CHAT_SMALL}`;
+const CHAT_LARGE_SLUG = `${CHANNEL_SLUG}/${VIDEO_CHAT_LARGE}`;
+
+async function expectResultSlugs(page: Page, slugs: string[]) {
+ // Wait until the search has settled by waiting for the summary line to
+ // report at least the expected count.
+ const cards = page.locator("[data-result-slug]");
+ await expect(async () => {
+ const got = await cards.evaluateAll((els) =>
+ els.map((e) => e.getAttribute("data-result-slug") ?? ""),
+ );
+ expect(got.slice().sort()).toEqual(slugs.slice().sort());
+ }).toPass({ timeout: 15_000 });
+}
+
+test.describe("composite search — query tree", () => {
+ test.beforeEach(async ({ page }) => {
+ await installRoutes(page);
+ });
+
+ test("single leaf: transcripts:'alpha' matches all three fixture videos", async ({
+ page,
+ }) => {
+ const tree: SGroup = {
+ k: "g",
+ o: "AND",
+ c: [{ k: "l", q: "alpha", s: "transcripts" }],
+ };
+ await page.goto(`/?qt=${qt(tree)}`);
+ await expectResultSlugs(page, [
+ TRANSCRIPT_ONLY_SLUG,
+ CHAT_SMALL_SLUG,
+ CHAT_LARGE_SLUG,
+ ]);
+ // Each result card carries a per-leaf hit section with the leaf's
+ // search term shown in monospace.
+ await expect(
+ page.locator("[data-leaf-section]").first(),
+ ).toBeVisible();
+ });
+
+ test("AND narrows: transcripts:'small chat' AND chat:'message' → only VIDEO_CHAT_SMALL", async ({
+ page,
+ }) => {
+ const tree: SGroup = {
+ k: "g",
+ o: "AND",
+ c: [
+ { k: "l", q: "small chat", s: "transcripts" },
+ { k: "l", q: "message", s: "chat" },
+ ],
+ };
+ await page.goto(`/?qt=${qt(tree)}`);
+ await expectResultSlugs(page, [CHAT_SMALL_SLUG]);
+ // Both leaves contributed hits — there should be two leaf-section bars.
+ const sections = page.locator(
+ `[data-result-slug="${CHAT_SMALL_SLUG}"] [data-leaf-section]`,
+ );
+ await expect(sections).toHaveCount(2);
+ });
+
+ test("OR widens: chat:'message' OR chat:'#0' surfaces both chat videos", async ({
+ page,
+ }) => {
+ const tree: SGroup = {
+ k: "g",
+ o: "AND",
+ c: [
+ {
+ k: "g",
+ o: "OR",
+ c: [
+ { k: "l", q: "message", s: "chat" },
+ { k: "l", q: "(#0)", s: "chat" },
+ ],
+ },
+ ],
+ };
+ await page.goto(`/?qt=${qt(tree)}`);
+ await expectResultSlugs(page, [CHAT_SMALL_SLUG, CHAT_LARGE_SLUG]);
+ });
+
+ test("NOT excludes: transcripts:'alpha' AND NOT chat:'message' → transcript_only + large_chat", async ({
+ page,
+ }) => {
+ const tree: SGroup = {
+ k: "g",
+ o: "AND",
+ c: [
+ { k: "l", q: "alpha", s: "transcripts" },
+ { k: "l", q: "message", s: "chat", n: 1 },
+ ],
+ };
+ await page.goto(`/?qt=${qt(tree)}`);
+ // VIDEO_CHAT_SMALL has "message" in chat — excluded.
+ // VIDEO_CHAT_LARGE has chat but no "message" word — kept.
+ // VIDEO_TRANSCRIPT_ONLY has no chat at all, so "not in chat:message" — kept.
+ await expectResultSlugs(page, [TRANSCRIPT_ONLY_SLUG, CHAT_LARGE_SLUG]);
+ });
+
+ test("hit-contribution toggle: filter-only leaf does not surface its hits", async ({
+ page,
+ }) => {
+ // chat leaf is a pure filter (h:0); only the transcripts leaf
+ // contributes hits. The matched video should have exactly one
+ // leaf-section (transcripts), not two.
+ const tree: SGroup = {
+ k: "g",
+ o: "AND",
+ c: [
+ { k: "l", q: "small chat", s: "transcripts" },
+ { k: "l", q: "message", s: "chat", h: 0 },
+ ],
+ };
+ await page.goto(`/?qt=${qt(tree)}`);
+ await expectResultSlugs(page, [CHAT_SMALL_SLUG]);
+ const sections = page.locator(
+ `[data-result-slug="${CHAT_SMALL_SLUG}"] [data-leaf-section]`,
+ );
+ await expect(sections).toHaveCount(1);
+ });
+
+ test("metadata leaf: title-scope matches by title text", async ({
+ page,
+ }) => {
+ const tree: SGroup = {
+ k: "g",
+ o: "AND",
+ c: [{ k: "l", q: "Transcript only", s: "metadata" }],
+ };
+ await page.goto(`/?qt=${qt(tree)}`);
+ await expectResultSlugs(page, [TRANSCRIPT_ONLY_SLUG]);
+ });
+
+ test("legacy URL: ?q=alpha auto-migrates to a single-leaf transcripts query", async ({
+ page,
+ }) => {
+ await page.goto(`/?q=alpha`);
+ await expectResultSlugs(page, [
+ TRANSCRIPT_ONLY_SLUG,
+ CHAT_SMALL_SLUG,
+ CHAT_LARGE_SLUG,
+ ]);
+ });
+
+ test("legacy URL: ?q=message&m=subs migrates to a single-leaf chat query", async ({
+ page,
+ }) => {
+ await page.goto(`/?q=message&m=subs`);
+ await expectResultSlugs(page, [CHAT_SMALL_SLUG]);
+ });
+
+ test("URL round-trip: builder reflects qt= tree on load and Share writes qt", async ({
+ page,
+ }) => {
+ const tree: SGroup = {
+ k: "g",
+ o: "AND",
+ c: [
+ { k: "l", q: "small chat", s: "transcripts" },
+ { k: "l", q: "message", s: "chat" },
+ ],
+ };
+ await page.goto(`/?qt=${qt(tree)}`);
+ await expectResultSlugs(page, [CHAT_SMALL_SLUG]);
+ // Two leaf inputs are rendered in the builder.
+ const leafInputs = page.locator('input[data-testid^="leaf-query-"]');
+ await expect(leafInputs).toHaveCount(2);
+ await expect(leafInputs.nth(0)).toHaveValue("small chat");
+ await expect(leafInputs.nth(1)).toHaveValue("message");
+ });
+
+ test("cache: editing one leaf re-runs only that leaf; sibling shows 'cached' badge", async ({
+ page,
+ }) => {
+ const tree: SGroup = {
+ k: "g",
+ o: "AND",
+ c: [
+ { k: "l", q: "small chat", s: "transcripts" },
+ { k: "l", q: "message", s: "chat" },
+ ],
+ };
+ await page.goto(`/?qt=${qt(tree)}`);
+ await expectResultSlugs(page, [CHAT_SMALL_SLUG]);
+
+ // Edit the chat leaf's query, then re-submit. The transcripts leaf's
+ // (canonical-hash, scope-hash) is unchanged, so it hits the layer cache
+ // and renders the `cached` badge.
+ const leafInputs = page.locator('input[data-testid^="leaf-query-"]');
+ const chatInput = leafInputs.nth(1);
+ await chatInput.fill("@user0");
+ await page.getByTestId("search-submit").click();
+ // After the re-run settles, the transcript-only leaf should be cached.
+ await expect(page.locator("[data-cache-hit]").first()).toBeVisible({
+ timeout: 15_000,
+ });
+ });
+
+ test("builder UI: + Add layer adds a second leaf and commits on Search", async ({
+ page,
+ }) => {
+ await page.goto("/");
+ // Start in compact mode: one leaf, "+ Add layer" pill below.
+ const compactAdd = page.getByTestId("compact-add-layer");
+ await expect(compactAdd).toBeVisible();
+ // Fill the first leaf via its query input.
+ const firstInput = page.locator('input[data-testid^="leaf-query-"]').first();
+ await firstInput.fill("small chat");
+ await compactAdd.click();
+ // Now there should be two leaves (the builder switched to the full
+ // group rendering).
+ const inputs = page.locator('input[data-testid^="leaf-query-"]');
+ await expect(inputs).toHaveCount(2);
+ await inputs.nth(1).fill("message");
+ // The new leaf defaults to scope=transcripts; switch it to chat.
+ const scopeSelects = page.locator('select[data-testid^="leaf-scope-"]');
+ await scopeSelects.nth(1).selectOption("chat");
+ await page.getByTestId("search-submit").click();
+ await expectResultSlugs(page, [CHAT_SMALL_SLUG]);
+ });
+});
diff --git a/export/e2e/share-current-search.spec.ts b/export/e2e/share-current-search.spec.ts
@@ -29,8 +29,15 @@ test.describe("search page — share current search", () => {
await search.fill("alpha");
await search.press("Enter");
- // After commit the URL only carries q (filters stripped).
- await expect(page).toHaveURL(/\?q=alpha$/);
+ // After commit the URL carries `qt=` (the composite-search tree) and
+ // filter params are stripped. Legacy `q=` is cleared.
+ await expect(page).toHaveURL(/[?&]qt=/);
+ const after = await urlParams(page);
+ expect(after.get("q")).toBeNull();
+ expect(after.get("qt")).not.toBeNull();
+ const tree = JSON.parse(decodeURIComponent(after.get("qt") ?? ""));
+ expect(tree.c[0].q).toBe("alpha");
+ expect(tree.c[0].s).toBe("transcripts");
// Click Share and inspect the clipboard.
await page.getByRole("button", { name: "Share current search" }).click();
@@ -40,7 +47,11 @@ test.describe("search page — share current search", () => {
const clip = await page.evaluate(() => navigator.clipboard.readText());
const url = new URL(clip);
expect(url.searchParams.get("fv")).toBe("1");
- expect(url.searchParams.get("q")).toBe("alpha");
+ // Composite query is encoded in `qt=`; legacy `q=` is omitted.
+ expect(url.searchParams.get("q")).toBeNull();
+ expect(url.searchParams.get("qt")).not.toBeNull();
+ const sharedTree = JSON.parse(url.searchParams.get("qt") ?? "");
+ expect(sharedTree.c[0].q).toBe("alpha");
// The only known channel is still selected (we only edited type/avail).
expect(url.searchParams.getAll("fc")).toEqual([CHANNEL]);
// Videos remains selected, Livestreams was unchecked.
@@ -84,12 +95,16 @@ test.describe("search page — share current search", () => {
// Trigger another commit to confirm strip-on-commit behavior.
await page.getByPlaceholder("Search transcripts...").press("Enter");
- await expect(page).toHaveURL(/\?q=alpha$/);
+ await expect(page).toHaveURL(/[?&]qt=/);
const after = await urlParams(page);
expect(after.get("fv")).toBeNull();
expect(after.get("fc")).toBeNull();
expect(after.get("ft")).toBeNull();
expect(after.get("fav")).toBeNull();
+ // Legacy `q=` is migrated to a single-leaf tree under `qt=`.
+ expect(after.get("q")).toBeNull();
+ const tree = JSON.parse(decodeURIComponent(after.get("qt") ?? ""));
+ expect(tree.c[0].q).toBe("alpha");
});
test("legacy link still applies filters and is stripped on next commit", async ({
@@ -109,11 +124,15 @@ test.describe("search page — share current search", () => {
page.getByRole("checkbox", { name: "Livestreams" }),
).toBeChecked();
- // Re-commit; legacy params are stripped.
+ // Re-commit; legacy filter params are stripped, and `q=` migrates to
+ // a single-leaf tree under `qt=`.
await page.getByPlaceholder("Search transcripts...").press("Enter");
- await expect(page).toHaveURL(/\?q=hello$/);
+ await expect(page).toHaveURL(/[?&]qt=/);
const after = await urlParams(page);
expect(after.get("nov")).toBeNull();
expect(after.get("nd")).toBeNull();
+ expect(after.get("q")).toBeNull();
+ const tree = JSON.parse(decodeURIComponent(after.get("qt") ?? ""));
+ expect(tree.c[0].q).toBe("hello");
});
});