Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 483487407374033ae6194ef85d336ec4dea8ad64
parent 9a3f4bb88574528cd569fc2147e754f0ce86d9ef
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 25 Sep 2026 20:16:35 -0400

common: a hub result names its archive; "N of M archives answered." above the results

ResultGroup.source (hub mode only, from siteTitleOf) is shown before the channel
on the card (data-testid result-source); the accent stripe stays. When an
archive in scope failed, one line above the results says how many answered,
names the ones that did not, and offers Retry (data-testid hub-scope-status).
Single-site: no federation, no source key, nothing rendered.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mcommon/components/SearchResults.tsx | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/components/SearchSessionContext.tsx | 18++++++++++++++++++
2 files changed, 70 insertions(+), 0 deletions(-)

diff --git a/common/components/SearchResults.tsx b/common/components/SearchResults.tsx @@ -190,6 +190,8 @@ export default function SearchResults() { </div> </div> + <FederationStatus /> + {view !== "chart" && resultGroups.length > 0 && ( <div data-testid="selection-toolbar" @@ -318,6 +320,47 @@ export default function SearchResults() { ); } +// Hub mode only: when an archive in scope failed to answer, one plain line +// above the results says how many did, names the ones that did not, and offers +// a Retry for them. The results from the archives that answered render as +// usual underneath — one member failing never empties the page. Nothing in +// single-site mode (no `federation`), and nothing while every archive is fine +// or still loading (the scope chips show loading). +function FederationStatus() { + const { federation } = useSearchData(); + if (!federation) return null; + const scoped = federation.sites.filter((s) => s.status !== "off"); + const failed = scoped.filter((s) => s.status === "failed"); + if (failed.length === 0) return null; + const answered = scoped.filter((s) => s.status === "ready").length; + const names = failed.map((s) => s.siteTitle); + const list = + names.length === 1 + ? names[0] + : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`; + return ( + <p + data-testid="hub-scope-status" + className="mb-2 flex flex-wrap items-baseline gap-x-2 text-sm text-muted-foreground" + > + <span> + {answered} of {scoped.length} archives answered. {list}{" "} + did not, so{" "} + {names.length === 1 ? "its" : "their"} videos are not in these results. + </span> + <button + type="button" + onClick={() => { + for (const s of failed) federation.retry(s.origin); + }} + className="text-brand transition-colors hover:underline" + > + Retry + </button> + </p> + ); +} + // ─── Result rendering ──────────────────────────────────────────────────────── type ResultRow = | { kind: "card"; cardIndex: number; group: ResultGroup } @@ -633,6 +676,15 @@ const ResultCard = memo(function ResultCard({ </> )} <span className="basis-full sm:basis-auto text-xs text-muted-foreground shrink-0"> + {/* Hub mode: the archive this came from, in text. */} + {group.source && ( + <> + <span data-testid="result-source" className="text-foreground/80"> + {group.source} + </span> + {" · "} + </> + )} {group.channel && `${group.channel} · `} {group.date} {group.hits.length > 0 && diff --git a/common/components/SearchSessionContext.tsx b/common/components/SearchSessionContext.tsx @@ -158,6 +158,9 @@ export type ResultGroup = { // Provenance accent of the source origin (hub mode only); undefined // single-site, so no marker renders. accent?: string; + // The source archive's title (hub mode only), named on the card beside the + // channel so provenance is not carried by colour alone. Absent single-site. + source?: string; // Set on rows from the social-post corpus. A post is not a video: it has no // timeline to seek, no livestream/age state and no VOD expiry, so the result // card renders a distinct variant rather than a degraded video card. @@ -486,6 +489,7 @@ function useSearchSessionState() { groups: manifestGroups, channelKeyOf, accentOf, + siteTitleOf, } = useSearchData(); const { summaries, loadedPages, pageCount, summariesReady } = summariesState; @@ -873,6 +877,16 @@ function useSearchSessionState() { // 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. + // `{ source }` for a hub result, `{}` single-site — so a single-site group + // carries no `source` key at all. + const sourceOf = useCallback( + (slug: string): { source?: string } => { + const title = siteTitleOf?.(splitId(slug).origin); + return title ? { source: title } : {}; + }, + [siteTitleOf], + ); + const resultGroups = useMemo<ResultGroup[]>(() => { if (!transcripts) return []; if (!hasActiveQuery) { @@ -897,6 +911,7 @@ function useSearchSessionState() { ? { curatedTags: t.curatedTags } : {}), accent: accentOf(splitId(t.slug).origin), + ...sourceOf(t.slug), }); } return out; @@ -924,6 +939,7 @@ function useSearchSessionState() { ? { curatedTags: t.curatedTags } : {}), accent: accentOf(splitId(t.slug).origin), + ...sourceOf(t.slug), }); } // Matched POST slugs. They are not in `transcripts` (a disjoint namespace), @@ -958,6 +974,7 @@ function useSearchSessionState() { uploadDate: post.uploadDate, hits: hitsBySlug.get(slug) ?? [], accent: accentOf(splitId(slug).origin), + ...sourceOf(slug), post, }); } @@ -973,6 +990,7 @@ function useSearchSessionState() { treeProgress, passesFilter, accentOf, + sourceOf, committedDateFrom, committedDateTo, committedStates,