commit 557da85c2551ee71dc143773a2d9bfa84a36486f
parent 96f61d843407b8b2a56c9a137fffa6a7bac9716f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 18 Apr 2026 01:16:11 -0400
Advanced options
Diffstat:
2 files changed, 223 insertions(+), 16 deletions(-)
diff --git a/app/TranscriptSearch.tsx b/app/TranscriptSearch.tsx
@@ -25,10 +25,10 @@ type HitGroup = {
hits: Hit[];
};
-const MAX_HITS = 500;
-const FETCH_CONCURRENCY = 6;
-const FLUSH_INTERVAL_MS = 120;
-const GROUPS_PAGE_SIZE = 50;
+const DEFAULT_MAX_HITS = 500;
+const DEFAULT_FETCH_CONCURRENCY = 6;
+const DEFAULT_FLUSH_INTERVAL_MS = 120;
+const DEFAULT_GROUPS_PAGE_SIZE = 50;
export default function TranscriptSearch() {
const {
@@ -90,16 +90,41 @@ export default function TranscriptSearch() {
const [totalToProcess, setTotalToProcess] = useState(0);
const [pipelineDone, setPipelineDone] = useState(false);
const [capped, setCapped] = useState(false);
- const [hitLimit, setHitLimit] = useState(MAX_HITS);
- const [groupsShown, setGroupsShown] = useState(GROUPS_PAGE_SIZE);
+
+ // Advanced options — configurable from the UI, pre-filled with defaults.
+ const [hitBatchValue, setHitBatchValue] = useState(DEFAULT_MAX_HITS);
+ const [hitBatchUnlimited, setHitBatchUnlimited] = useState(false);
+ const [groupsBatchValue, setGroupsBatchValue] = useState(
+ DEFAULT_GROUPS_PAGE_SIZE,
+ );
+ const [groupsBatchUnlimited, setGroupsBatchUnlimited] = useState(false);
+ const [fetchConcurrency, setFetchConcurrency] = useState(
+ DEFAULT_FETCH_CONCURRENCY,
+ );
+ const [flushIntervalMs, setFlushIntervalMs] = useState(
+ DEFAULT_FLUSH_INTERVAL_MS,
+ );
+ const hitBatchSize = hitBatchUnlimited ? Infinity : hitBatchValue;
+ const groupsBatchSize = groupsBatchUnlimited
+ ? Infinity
+ : groupsBatchValue;
+
+ const [hitLimit, setHitLimit] = useState(DEFAULT_MAX_HITS);
+ const [groupsShown, setGroupsShown] = useState<number>(
+ DEFAULT_GROUPS_PAGE_SIZE,
+ );
const pipelineRef = useRef<PipelineController | null>(null);
const { openTranscript } = usePlayer();
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).
useEffect(() => {
- setHitLimit(MAX_HITS);
- setGroupsShown(GROUPS_PAGE_SIZE);
+ setHitLimit(hitBatchSize);
+ setGroupsShown(groupsBatchSize);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [trimmed, useRegex]);
const channelOptions = useMemo<string[]>(() => {
@@ -165,8 +190,8 @@ export default function TranscriptSearch() {
useRegex,
regex: regex.re,
initialHitLimit: hitLimit,
- concurrency: FETCH_CONCURRENCY,
- flushIntervalMs: FLUSH_INTERVAL_MS,
+ concurrency: fetchConcurrency,
+ flushIntervalMs,
emit: (u) => {
setHitsBySlug(u.hitsBySlug);
setTotalHits(u.totalHits);
@@ -356,6 +381,78 @@ export default function TranscriptSearch() {
</div>
)}
+ <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
+ </summary>
+ <div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 p-4 rounded-md border border-zinc-200 dark:border-zinc-800 bg-white/40 dark:bg-zinc-900/40">
+ <LimitField
+ label="Max hits per batch"
+ hint="Pause after this many matches; “Show more” adds another batch."
+ value={hitBatchValue}
+ unlimited={hitBatchUnlimited}
+ defaultValue={DEFAULT_MAX_HITS}
+ onValueChange={setHitBatchValue}
+ onUnlimitedChange={setHitBatchUnlimited}
+ />
+ <LimitField
+ label="Videos per page"
+ hint="How many matching videos to show at a time."
+ value={groupsBatchValue}
+ unlimited={groupsBatchUnlimited}
+ defaultValue={DEFAULT_GROUPS_PAGE_SIZE}
+ onValueChange={setGroupsBatchValue}
+ onUnlimitedChange={setGroupsBatchUnlimited}
+ />
+ <NumberField
+ label="Fetch concurrency"
+ hint="Parallel transcript downloads while searching. Applies on next search."
+ value={fetchConcurrency}
+ defaultValue={DEFAULT_FETCH_CONCURRENCY}
+ min={1}
+ max={32}
+ onChange={setFetchConcurrency}
+ />
+ <NumberField
+ label="Flush interval (ms)"
+ hint="UI update cadence during search. Applies on next search."
+ value={flushIntervalMs}
+ defaultValue={DEFAULT_FLUSH_INTERVAL_MS}
+ min={16}
+ max={2000}
+ onChange={setFlushIntervalMs}
+ />
+ <div className="sm:col-span-2 flex flex-wrap gap-2 pt-1">
+ <button
+ type="button"
+ onClick={() => {
+ setHitBatchUnlimited(true);
+ setGroupsBatchUnlimited(true);
+ setHitLimit(Infinity);
+ setGroupsShown(Infinity);
+ }}
+ className="px-3 py-1.5 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-xs"
+ >
+ Search through all transcripts
+ </button>
+ <button
+ type="button"
+ onClick={() => {
+ setHitBatchValue(DEFAULT_MAX_HITS);
+ setHitBatchUnlimited(false);
+ setGroupsBatchValue(DEFAULT_GROUPS_PAGE_SIZE);
+ setGroupsBatchUnlimited(false);
+ setFetchConcurrency(DEFAULT_FETCH_CONCURRENCY);
+ setFlushIntervalMs(DEFAULT_FLUSH_INTERVAL_MS);
+ }}
+ className="px-3 py-1.5 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-xs"
+ >
+ Reset to defaults
+ </button>
+ </div>
+ </div>
+ </details>
+
{!trimmed && (
<p className="text-sm text-zinc-500">
Enter a query to search transcripts.
@@ -449,8 +546,8 @@ export default function TranscriptSearch() {
<button
type="button"
onClick={() => {
- setGroupsShown((n) => n + GROUPS_PAGE_SIZE);
- if (capped) setHitLimit((l) => l + MAX_HITS);
+ 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"
>
@@ -473,6 +570,109 @@ function sameSet<T>(a: Set<T>, b: Set<T>): boolean {
return true;
}
+function NumberField({
+ label,
+ hint,
+ value,
+ defaultValue,
+ min,
+ max,
+ onChange,
+ disabled,
+}: {
+ label: string;
+ hint?: string;
+ value: number;
+ defaultValue: number;
+ min: number;
+ max: number;
+ onChange: (n: number) => void;
+ disabled?: boolean;
+}) {
+ return (
+ <label className="flex flex-col gap-1">
+ <span className="text-xs font-medium text-zinc-700 dark:text-zinc-300">
+ {label}
+ </span>
+ <input
+ type="number"
+ min={min}
+ max={max}
+ value={Number.isFinite(value) ? value : ""}
+ placeholder={String(defaultValue)}
+ disabled={disabled}
+ onChange={(e) => {
+ const raw = e.target.value;
+ if (raw === "") {
+ onChange(defaultValue);
+ return;
+ }
+ const n = Math.max(min, Math.min(max, Math.floor(Number(raw))));
+ if (Number.isFinite(n)) onChange(n);
+ }}
+ className="w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm disabled:opacity-50"
+ />
+ {hint && <span className="text-xs text-zinc-500">{hint}</span>}
+ </label>
+ );
+}
+
+function LimitField({
+ label,
+ hint,
+ value,
+ unlimited,
+ defaultValue,
+ onValueChange,
+ onUnlimitedChange,
+}: {
+ label: string;
+ hint?: string;
+ value: number;
+ unlimited: boolean;
+ defaultValue: number;
+ onValueChange: (n: number) => void;
+ onUnlimitedChange: (b: boolean) => void;
+}) {
+ return (
+ <div className="flex flex-col gap-1">
+ <div className="flex items-baseline justify-between gap-2">
+ <span className="text-xs font-medium text-zinc-700 dark:text-zinc-300">
+ {label}
+ </span>
+ <label className="flex items-center gap-1 text-xs select-none">
+ <input
+ type="checkbox"
+ checked={unlimited}
+ onChange={(e) => onUnlimitedChange(e.target.checked)}
+ className="accent-blue-600"
+ />
+ No limit
+ </label>
+ </div>
+ <input
+ type="number"
+ min={1}
+ max={1_000_000}
+ value={unlimited ? "" : value}
+ placeholder={unlimited ? "∞" : String(defaultValue)}
+ disabled={unlimited}
+ onChange={(e) => {
+ const raw = e.target.value;
+ if (raw === "") {
+ onValueChange(defaultValue);
+ return;
+ }
+ const n = Math.max(1, Math.floor(Number(raw)));
+ if (Number.isFinite(n)) onValueChange(n);
+ }}
+ className="w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm disabled:opacity-50"
+ />
+ {hint && <span className="text-xs text-zinc-500">{hint}</span>}
+ </div>
+ );
+}
+
function formatSeconds(s: number): string {
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
diff --git a/app/searchPipeline.ts b/app/searchPipeline.ts
@@ -46,7 +46,12 @@ export function createSearchPipeline(
} = config;
let cancelled = false;
+ // `idx` is the next-to-claim cursor (leading edge); `completed` counts
+ // slugs that workers have finished scanning (trailing edge). We report
+ // `completed` to the UI so progress reflects actual work done, not
+ // in-flight claims — and it never exceeds slugs.length.
let idx = 0;
+ let completed = 0;
let totalSoFar = 0;
let hitLimit = initialHitLimit;
let activeWorkers = 0;
@@ -58,8 +63,10 @@ export function createSearchPipeline(
emit({
hitsBySlug: { ...localHits },
totalHits: totalSoFar,
- processed: idx,
+ processed: completed,
totalToProcess: slugs.length,
+ // `capped` keys off `idx` (claimed), not `completed` — raising the cap
+ // only helps if there are still unclaimed slugs a worker can pick up.
capped: totalSoFar >= hitLimit && idx < slugs.length,
done,
...overrides,
@@ -90,14 +97,13 @@ export function createSearchPipeline(
try {
while (!cancelled) {
if (totalSoFar >= hitLimit) return;
+ if (idx >= slugs.length) return;
const my = idx++;
- if (my >= slugs.length) return;
const slug = slugs[my];
try {
const full = await fetchTranscript(slug);
if (cancelled) return;
- if (totalSoFar >= hitLimit) return;
- if (full.cues) {
+ if (full.cues && totalSoFar < hitLimit) {
const remaining = hitLimit - totalSoFar;
const hits = findHitsInCues(
full.cues,
@@ -114,6 +120,7 @@ export function createSearchPipeline(
} catch {
// ignore per-transcript failures
}
+ completed++;
scheduleFlush();
}
} finally {