Archilyzer · Source

archilyzer

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

commit cb607466e6c2478ae1a73367d6d459a317dc4f93
parent 533fa0affd829440ceb8c353dd48911d6005647a
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 28 Sep 2026 02:34:01 -0400

hub: /ask waits for the hub's own list and says what it searches; a member's missing live chat is noted on its chip with a live-chat-only Retry — MultiSiteDataProvider listed + federation.listed / retryLiveChat / liveChatMissing (the subs rule keys on errorUpdateCount, so the archive stays ready through the Retry); three DRAFT copy constants in hubScopeCopy.ts (NO_ARCHIVES_ON_HUB, LIVE_CHAT_MISSING, ASK_SCOPE_LINE); AskScope, LinkedCopy; e2e: ask.spec (a hub with no archives, x3), federated-search.spec (the list, the scope line, the live-chat Retry)

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

Diffstat:
Mcommon/components/SearchDataContext.tsx | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mexport/app/ask/AskChat.tsx | 39+++++++++++++++------------------------
Mexport/app/ask/AskHub.tsx | 13+++++++++----
Aexport/app/ask/AskScope.tsx | 26++++++++++++++++++++++++++
Mexport/app/ask/Composer.tsx | 3++-
Aexport/app/ask/LinkedCopy.tsx | 22++++++++++++++++++++++
Mexport/app/ask/hubScopeCopy.ts | 26++++++++++++++++++++++++++
Mexport/app/components/hub/HubScope.tsx | 30+++++++++++++++++++++++++++++-
Mexport/app/components/hub/useHubSites.ts | 21+++++++++++++++------
Mexport/e2e-hub/ask.spec.ts | 79++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mexport/e2e-hub/federated-search.spec.ts | 170+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
11 files changed, 444 insertions(+), 78 deletions(-)

diff --git a/common/components/SearchDataContext.tsx b/common/components/SearchDataContext.tsx @@ -106,13 +106,26 @@ export type FederatedSiteState = { count?: number; // Why it failed, when it did. error?: string; + // Ready only: its live-chat (subs) manifest could not be read, even after its + // retry, so its videos are searched WITHOUT their live chat. `retrying` while + // a live-chat Retry (FederationState.retryLiveChat) is in flight; `error` is + // the last failure's reason. + liveChatMissing?: { retrying: boolean; error?: string }; }; export type FederationState = { // In registry order, off archives included. sites: FederatedSiteState[]; + // Whether `sites` is the hub's whole list (the provider's `listed` prop): + // false while the hub's own list of archives is still arriving, so an empty + // `sites` then means "not yet", not "none". + listed: boolean; // Refetch everything of this archive that failed. retry: (origin: string) => void; + // Refetch this archive's live-chat (subs) manifest alone — the Retry beside + // a ready archive's `liveChatMissing` note. Its videos stay in the search + // while it runs. + retryLiveChat: (origin: string) => void; }; // Single-site mode has no provenance accents; a module constant keeps the @@ -284,13 +297,22 @@ function errorText(e: unknown): string | undefined { // ready (the hub's search page, where results should not wait for the slowest // member). Without it, summariesReady waits until every in-scope archive has // settled, ready or failed (the /ask chat, which grounds in what it was given). +// +// `listed` (default true): whether `sites` is the hub's WHOLE list yet. /ask +// passes false until the hub's own list (`/hub-sites.json`) has been answered: +// the archives a browser added are known at once, the hub's own a moment later, +// and a non-progressive surface is not ready over the first few alone. Its +// archives are fetched meanwhile; only readiness waits. `federation.listed` +// carries it, so an empty list can be told apart from one not in yet. export function MultiSiteDataProvider({ sites, progressive = false, + listed = true, children, }: { sites: FederatedSite[]; progressive?: boolean; + listed?: boolean; children: ReactNode; }) { const queryClient = useQueryClient(); @@ -353,6 +375,9 @@ export function MultiSiteDataProvider({ // is retried once and then counts as SETTLED: the archive is ready without // its live chat. Live chat is the auxiliary layer; its failure never takes an // archive's videos out of the search or makes it read as "did not answer". + // The archive's chip notes it instead (`liveChatMissing`), with a Retry for + // the live chat alone, and the archive stays ready while that runs (the rule + // is in step 3). const subsQueries = useQueries({ queries: sites.map((s) => ({ queryKey: ["subs-manifest", s.origin], @@ -415,15 +440,36 @@ export function MultiSiteDataProvider({ // Subs and posts manifests resolve a 404 to an empty manifest; posts never // errors. A subs query that still errors after its retry counts as settled // (the archive is ready without its live chat) and never as a failure. - const settled = (q: { isSuccess: boolean; isError: boolean; isFetching: boolean } | undefined) => - !!q && (q.isSuccess || (q.isError && !q.isFetching)); + // + // "Has errored" is `errorUpdateCount > 0`, not `isError`: the count moves + // only once the query's own retry is spent, and it survives a refetch — + // TanStack puts an errored query with no data back to `pending` while it + // refetches, so `isError` would take the archive (and its videos) out of + // the search for the length of a live-chat Retry. With the count, the + // archive stays ready through it, still noted as missing its live chat. + const posts = postsQueries[i]; + const postsSettled = !!posts && (posts.isSuccess || posts.isError); + const subs = subsQueries[i]; + const subsErrored = !!subs && subs.data === undefined && subs.errorUpdateCount > 0; + const subsSettled = !!subs && (subs.data !== undefined || subsErrored); if ( m?.isSuccess && pages.length === m.data.pageCount && pages.every((p) => p.isSuccess) && - settled(subsQueries[i]) && - settled(postsQueries[i]) + subsSettled && + postsSettled ) { + if (subsErrored) { + const why = errorText(subs.error); + return { + ...withCount, + status: "ready", + liveChatMissing: { + retrying: subs.isFetching, + ...(why ? { error: why } : {}), + }, + }; + } return { ...withCount, status: "ready" }; } const fetching = !!m?.isFetching || pages.some((p) => p.isFetching); @@ -438,7 +484,11 @@ export function MultiSiteDataProvider({ return { ...withCount, status: "loading" }; }); const statusKey = rawStates - .map((s) => `${s.origin}|${s.siteTitle}|${s.accent ?? ""}|${s.status}|${s.count ?? ""}|${s.error ?? ""}`) + .map((s) => { + const chat = s.liveChatMissing; + const chatKey = chat ? `${chat.retrying ? "retrying" : "missing"}:${chat.error ?? ""}` : ""; + return `${s.origin}|${s.siteTitle}|${s.accent ?? ""}|${s.status}|${s.count ?? ""}|${s.error ?? ""}|${chatKey}`; + }) .join("\n"); const siteStates = useMemo( () => rawStates, @@ -453,14 +503,18 @@ export function MultiSiteDataProvider({ ); const readyKey = Array.from(readyOrigins).join("\u0000"); const scoped = siteStates.filter((s) => s.status !== "off"); - // Nothing in scope — every archive switched off, or the hub's list not - // arrived yet — is NOT ready for a surface that answers from the whole - // federation (not `progressive`: the /ask chat). There is nothing to ground a - // question in, and "ready" there would send one over zero records. The - // progressive front page settles on an empty scope instead, so its results - // read "no videos" rather than loading forever; its chips and "Searching 0 - // archives" say why. + // Nothing in scope — every archive switched off, the hub's list not arrived + // yet, or a hub with no archives at all — is NOT ready for a surface that + // answers from the whole federation (not `progressive`: the /ask chat). There + // is nothing to ground a question in, and "ready" there would send one over + // zero records. Nor is a list that is not yet the whole list (`listed` false: + // the archives this browser added are in, the hub's own still arriving). /ask + // says which it is once the list is in (`federation.listed`). The progressive + // front page settles on an empty scope instead, so its results read "no + // videos" rather than loading forever; its chips and "Searching 0 archives" + // say why. const allSettled = + listed && (progressive || scoped.length > 0) && scoped.every((s) => s.status === "ready" || s.status === "failed"); const summariesReady = allSettled || (progressive && readyOrigins.size > 0); @@ -730,9 +784,20 @@ export function MultiSiteDataProvider({ }, [queryClient], ); + // The live chat alone: a ready archive's subs manifest, which settled in + // error. Nothing else of the archive is refetched, and it stays ready. + const retryLiveChat = useCallback( + (origin: string) => { + void queryClient.refetchQueries({ + queryKey: ["subs-manifest", origin], + exact: true, + }); + }, + [queryClient], + ); const federation = useMemo<FederationState>( - () => ({ sites: siteStates, retry }), - [siteStates, retry], + () => ({ sites: siteStates, listed, retry, retryLiveChat }), + [siteStates, listed, retry, retryLiveChat], ); const value = useMemo<SearchDataValue>( diff --git a/export/app/ask/AskChat.tsx b/export/app/ask/AskChat.tsx @@ -1,7 +1,6 @@ "use client"; import { useEffect, useMemo, useRef, useState } from "react"; -import Link from "next/link"; import { ArrowDownIcon, PlusIcon } from "lucide-react"; import { usePlayerOptional } from "yt-dlp-transcript-common/components/PlayerProvider"; import { useSearchData } from "yt-dlp-transcript-common/components/SearchDataContext"; @@ -15,7 +14,8 @@ import { GroundingPalette } from "./GroundingPalette"; import { SavedChatsRow } from "./SavedChatsRow"; import { MessageBubble } from "./MessageBubble"; import { Composer } from "./Composer"; -import { NO_ARCHIVES_IN_SCOPE, copyWithLink } from "./hubScopeCopy"; +import { NO_ARCHIVES_IN_SCOPE, NO_ARCHIVES_ON_HUB } from "./hubScopeCopy"; +import { LinkedCopy } from "./LinkedCopy"; export default function AskChat() { const s = useAskChat(); @@ -31,16 +31,19 @@ export default function AskChat() { markdownOn, } = s; - // Hub only: this browser switched every archive off with the front page's - // scope chips. The provider then never reads ready (nothing to ground in), and - // the composer says why instead of "Loading transcripts…". Single-site has no - // `federation`. + // Hub only (single-site has no `federation`): nothing to search. The + // provider never reads ready over an empty scope, so once the hub's list is + // in the composer says why instead of "Loading transcripts…" — the hub has + // no archives at all, or this browser switched every one off with the front + // page's scope chips. Before the list is in, it is still loading. const { federation } = useSearchData(); - const noneInScope = - !!federation && - federation.sites.length > 0 && - federation.sites.every((f) => f.status === "off"); - const noneLine = copyWithLink(NO_ARCHIVES_IN_SCOPE); + const blockedCopy = !federation?.listed + ? undefined + : federation.sites.length === 0 + ? NO_ARCHIVES_ON_HUB + : federation.sites.every((f) => f.status === "off") + ? NO_ARCHIVES_IN_SCOPE + : undefined; // Opening a citation seeks the shared transcript modal to the cited line. It // writes ?v=&t=&vm= via replaceState on this same /ask route (no navigation, @@ -475,19 +478,7 @@ export default function AskChat() { stop={s.stop} busy={busy} summariesReady={summariesReady} - blocked={ - noneInScope ? ( - <> - {noneLine.before} - {noneLine.link && ( - <Link href="/" className="text-brand underline-offset-2 hover:underline"> - {noneLine.link} - </Link> - )} - {noneLine.after} - </> - ) : undefined - } + blocked={blockedCopy ? <LinkedCopy copy={blockedCopy} /> : undefined} hasKey={!!apiKey.trim()} markdownOn={markdownOn} setMarkdownOn={s.setMarkdownOn} diff --git a/export/app/ask/AskHub.tsx b/export/app/ask/AskHub.tsx @@ -8,8 +8,11 @@ // with its chip on the front page is not fetched here either, so the chat // grounds in what the search showed. Not progressive: the chat waits until // every archive in scope has settled, so it never answers from a half-loaded -// federation — and with NONE in scope (every chip off, or the list not in yet) -// it is not ready at all; AskChat then says so (NO_ARCHIVES_IN_SCOPE). +// federation — nor before the hub's own list is in (`listed`: the archives a +// browser added are known at once, /hub-sites.json's a moment later). With +// NONE in scope it is not ready at all; once the list is in AskChat says why +// (NO_ARCHIVES_ON_HUB, NO_ARCHIVES_IN_SCOPE), and AskScope says how many of +// the hub's archives the chat searches (ASK_SCOPE_LINE). import { PlayerProvider } from "yt-dlp-transcript-common/components/PlayerProvider"; import TranscriptModal from "yt-dlp-transcript-common/components/TranscriptModal"; @@ -18,6 +21,7 @@ import { MultiSiteDataProvider } from "yt-dlp-transcript-common/components/Searc import { SearchSessionProvider } from "yt-dlp-transcript-common/components/SearchSessionContext"; import { useFederatedSites } from "../components/hub/useHubSites"; import AskChat from "./AskChat"; +import AskScope from "./AskScope"; // `transcriptDownloads` comes from the server parent's currentSite() (a client // component cannot read site.json): false hides the modal's per-video export @@ -27,7 +31,7 @@ export default function AskHub({ }: { transcriptDownloads?: boolean; }) { - const { federated } = useFederatedSites(); + const { federated, listed } = useFederatedSites(); // The whole provider stack, in the order SiteWorkspace mounts it for a single // site — PlayerProvider, then the data source, then the session — because @@ -41,7 +45,8 @@ export default function AskHub({ // the stack, never an opt-out of prerendering. return ( <PlayerProvider features={{ transcriptDownloads }}> - <MultiSiteDataProvider sites={federated}> + <MultiSiteDataProvider sites={federated} listed={listed}> + <AskScope /> <SearchSessionProvider> <AskChat /> </SearchSessionProvider> diff --git a/export/app/ask/AskScope.tsx b/export/app/ask/AskScope.tsx @@ -0,0 +1,26 @@ +"use client"; + +// Hub /ask only: one line under the page's header saying what the chat +// searches — how many of the hub's archives are in scope, the rest switched off +// with the front page's chips (ASK_SCOPE_LINE, the operator's copy). Nothing +// until the hub's list is in (the count would move), and nothing with none in +// scope: the composer's blocked line says that. + +import { useSearchData } from "yt-dlp-transcript-common/components/SearchDataContext"; +import { ASK_SCOPE_LINE } from "./hubScopeCopy"; +import { LinkedCopy } from "./LinkedCopy"; + +export default function AskScope() { + const { federation } = useSearchData(); + if (!federation?.listed) return null; + const total = federation.sites.length; + const inScope = federation.sites.filter((s) => s.status !== "off").length; + if (inScope === 0) return null; + const line = ASK_SCOPE_LINE(inScope, total); + if (!line) return null; + return ( + <p data-testid="ask-scope" className="-mt-2 text-sm text-muted-foreground"> + <LinkedCopy copy={line} /> + </p> + ); +} diff --git a/export/app/ask/Composer.tsx b/export/app/ask/Composer.tsx @@ -10,7 +10,8 @@ type Props = { busy: boolean; summariesReady: boolean; // Why the chat cannot ask at all, shown as one line under the box (the box is - // disabled). Set on the hub when every archive is switched off. + // disabled). Set on the hub when it has no archives, or every one is switched + // off. blocked?: ReactNode; hasKey: boolean; markdownOn: boolean; diff --git a/export/app/ask/LinkedCopy.tsx b/export/app/ask/LinkedCopy.tsx @@ -0,0 +1,22 @@ +"use client"; + +// A copy line from hubScopeCopy.ts as a reader sees it: its one [bracketed] +// phrase, when it has one, a link to the hub's front page. + +import Link from "next/link"; +import { copyWithLink } from "./hubScopeCopy"; + +export function LinkedCopy({ copy }: { copy: string }) { + const { before, link, after } = copyWithLink(copy); + return ( + <> + {before} + {link && ( + <Link href="/" className="text-brand underline-offset-2 hover:underline"> + {link} + </Link> + )} + {after} + </> + ); +} diff --git a/export/app/ask/hubScopeCopy.ts b/export/app/ask/hubScopeCopy.ts @@ -7,6 +7,32 @@ export const NO_ARCHIVES_IN_SCOPE = "No archives selected. Choose some on the [hub's front page] to ask."; +// DRAFT (release 11, 2026-09-28) — awaiting the operator's ruling +// The hub's /ask line for a hub with NO archives at all: its /hub-sites.json +// lists none (a fresh self-hosted hub) or could not be read, and this browser +// has added none. The composer is disabled and this line says why, instead of +// "Loading transcripts…" forever. The [bracketed] words link to the hub's front +// page, where the form to add an archive is; drop the brackets for no link. +export const NO_ARCHIVES_ON_HUB = + "This hub has no archives yet. Add one on the [hub's front page] to ask."; + +// DRAFT (release 11, 2026-09-28) — awaiting the operator's ruling +// The note on a hub scope chip (the front page) when that archive's live chat +// could not be read: its videos are searched, its live chat is not. A Retry +// for the live chat alone sits beside it. Chip notes are lower case, like the +// chip's own "loading…" and "failed". +export const LIVE_CHAT_MISSING = "live chat didn't load"; + +// DRAFT (release 11, 2026-09-28) — awaiting the operator's ruling +// The one line at the top of the hub's /ask that says what the chat searches: +// `inScope` of the hub's `total` archives, the rest switched off with the +// front page's scope chips. The [bracketed] words link to the front page; +// drop the brackets for no link. Return null to show no line — e.g. +// `inScope === total ? null : …` to show it only when some archives are off. +// Not shown when none are in scope (NO_ARCHIVES_IN_SCOPE says so instead). +export const ASK_SCOPE_LINE = (inScope: number, total: number): string | null => + `Searching ${inScope} of ${total} ${total === 1 ? "archive" : "archives"} — [change on the front page].`; + // A copy line split around its one [bracketed] link. export function copyWithLink(copy: string): { before: string; diff --git a/export/app/components/hub/HubScope.tsx b/export/app/components/hub/HubScope.tsx @@ -6,12 +6,16 @@ // archive is doing — loading, its record count once ready (its summaries // manifest's totalCount, the number the results header counts), or failed with // a Retry beside it — so a slow or dead member is visible, not a silent gap. +// A ready archive whose live chat could not be read says that too +// (LIVE_CHAT_MISSING), with a Retry for its live chat alone: its videos stay +// in the search meanwhile. import { cn } from "yt-dlp-transcript-common/lib/utils"; import { useSearchData, type FederatedSiteState, } from "yt-dlp-transcript-common/components/SearchDataContext"; +import { LIVE_CHAT_MISSING } from "../../ask/hubScopeCopy"; function detail(s: FederatedSiteState): string { switch (s.status) { @@ -33,7 +37,7 @@ export default function HubScope({ }) { const { federation } = useSearchData(); if (!federation || federation.sites.length === 0) return null; - const { sites, retry } = federation; + const { sites, retry, retryLiveChat } = federation; return ( <div @@ -96,6 +100,30 @@ export default function HubScope({ Retry </button> )} + {s.status === "ready" && s.liveChatMissing && ( + <> + <span + data-testid="hub-scope-live-chat" + className="flex items-center border-l border-border px-2.5 py-1.5 text-xs text-muted-foreground" + > + {LIVE_CHAT_MISSING} + </span> + <button + type="button" + onClick={() => retryLiveChat(s.origin)} + disabled={s.liveChatMissing.retrying} + aria-label={`Retry live chat from ${s.siteTitle}`} + title={ + s.liveChatMissing.error + ? `${s.liveChatMissing.error} — try again` + : "Try again" + } + className="border-l border-border px-2.5 py-1.5 text-xs text-brand hover:bg-accent disabled:opacity-50 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring" + > + Retry + </button> + </> + )} </span> ); })} diff --git a/export/app/components/hub/useHubSites.ts b/export/app/components/hub/useHubSites.ts @@ -13,6 +13,12 @@ // requested together, so the wait is the gap between two requests already in // flight. The archives a visitor added follow, in the order they were added, // each in its own accent or none. +// +// `listed` says the list is the hub's WHOLE list: `/hub-sites.json` has been +// answered (siteRegistry's `builtinsLoaded`) and the summary has settled. Until +// then it may hold only the archives this browser added (they are read from +// storage at once). A surface that must not act on half the hub — /ask — +// waits for it; the front page does not. import { useMemo } from "react"; import { @@ -25,8 +31,9 @@ import { useHubScope } from "./useHubScope"; import { useHubSummary } from "./useHubSummary"; export function useHubSites() { - const { sites, removeSite } = useRegistry(); + const { sites, removeSite, builtinsLoaded } = useRegistry(); const { summary, settled } = useHubSummary(); + const listed = builtinsLoaded && settled; return useMemo(() => { const official = settled ? officialInstances( @@ -40,18 +47,20 @@ export function useHubSites() { ...official.map((o) => ({ ...o.site, accent: o.accent })), ...added, ]; - return { official, added, all, summary, removeSite }; - }, [sites, summary, settled, removeSite]); + return { official, added, all, summary, removeSite, listed }; + }, [sites, summary, settled, removeSite, listed]); } // What the hub's search surfaces hand MultiSiteDataProvider: every archive in // the one order, with its colour and this browser's scope (the chips on the -// front page; an archive switched off there is not fetched). +// front page; an archive switched off there is not fetched), and whether that +// is the whole list yet (`listed`, above). export function useFederatedSites(): { federated: FederatedSite[]; toggle: (origin: string) => void; + listed: boolean; } { - const { all } = useHubSites(); + const { all, listed } = useHubSites(); const { isOn, toggle } = useHubScope(); const federated = useMemo<FederatedSite[]>( () => @@ -63,5 +72,5 @@ export function useFederatedSites(): { })), [all, isOn], ); - return { federated, toggle }; + return { federated, toggle, listed }; } diff --git a/export/e2e-hub/ask.spec.ts b/export/e2e-hub/ask.spec.ts @@ -1,4 +1,9 @@ -import { expect, test, type Page } from "@playwright/test"; +import { expect, test, type Page, type Route } from "@playwright/test"; +import { + NO_ARCHIVES_ON_HUB, + copyText, + copyWithLink, +} from "../app/ask/hubScopeCopy"; // The hub's /ask route. // @@ -11,15 +16,21 @@ import { expect, test, type Page } from "@playwright/test"; // other half, covered by the 2-origin suite which builds the hub for real. // No built-in pool: this route has to stand up on a hub with no archives, -// which is what a fresh hub is. -async function stubBuiltins(page: Page) { - await page.route("**/hub-sites.json", (r) => - r.fulfill({ - status: 200, - contentType: "application/json", - headers: { "access-control-allow-origin": "*" }, - body: "[]", - }), +// which is what a fresh hub is. `answer` is how /hub-sites.json replies: an +// empty list, or a failure (the registry never asks twice). +async function stubBuiltins( + page: Page, + answer: "empty" | "error" | "abort" = "empty", +) { + await page.route("**/hub-sites.json", (r: Route) => + answer === "abort" + ? r.abort() + : r.fulfill({ + status: answer === "error" ? 500 : 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: answer === "error" ? "" : "[]", + }), ); } @@ -38,13 +49,12 @@ test.describe("hub /ask", () => { ).toBeVisible(); // The composer is AskChat's whole point, and it only renders once the - // provider stack AskHub now mounts is there. Located by its placeholder, - // the way ask-chat.spec.ts does it — the first textbox on the page is the - // provider panel's API-key field, not this. - const box = page.getByPlaceholder( - /Ask about the transcripts|Loading transcripts/, - ); - await expect(box).toBeVisible(); + // provider stack AskHub now mounts is there. On a hub with no archives it + // is there but blocked (below), so it is found by its form. + const composer = page + .locator("form") + .filter({ has: page.getByRole("button", { name: "Ask", exact: true }) }); + await expect(composer.locator("textarea")).toBeVisible(); await expect( page.getByRole("button", { name: "Ask", exact: true }), ).toBeVisible(); @@ -52,4 +62,39 @@ test.describe("hub /ask", () => { // A missing provider surfaces as a client-side throw, not a blank page. expect(errors).toEqual([]); }); + + // Release 11 (O1): a hub with no archives at all used to read "Loading + // transcripts…" forever on /ask. Once /hub-sites.json has answered — an + // empty list, or a failure the registry does not retry — and this browser + // has added none, the composer says so instead (NO_ARCHIVES_ON_HUB). + for (const answer of ["empty", "error", "abort"] as const) { + test(`a hub with no archives (hub-sites.json ${answer}) says so and cannot ask`, async ({ + page, + }) => { + await stubBuiltins(page, answer); + await page.goto("/ask"); + + const composer = page + .locator("form") + .filter({ has: page.getByRole("button", { name: "Ask", exact: true }) }); + const blocked = composer.getByTestId("ask-blocked"); + await expect(blocked).toHaveText(copyText(NO_ARCHIVES_ON_HUB)); + await expect(blocked).toHaveAttribute("role", "status"); + // Its [bracketed] words (when the copy has any) link to the front page, + // where the form to add an archive is. + const { link } = copyWithLink(NO_ARCHIVES_ON_HUB); + if (link) { + await expect( + blocked.getByRole("link", { name: link, exact: true }), + ).toHaveAttribute("href", "/"); + } + await expect(composer.locator("textarea")).toBeDisabled(); + await expect( + composer.getByRole("button", { name: "Ask", exact: true }), + ).toBeDisabled(); + await expect(page.getByPlaceholder("Loading transcripts…")).toHaveCount(0); + // No archives, so no "Searching N of M" line either. + await expect(page.getByTestId("ask-scope")).toHaveCount(0); + }); + } }); diff --git a/export/e2e-hub/federated-search.spec.ts b/export/e2e-hub/federated-search.spec.ts @@ -1,5 +1,7 @@ import { expect, test, type Page, type Route } from "@playwright/test"; import { + ASK_SCOPE_LINE, + LIVE_CHAT_MISSING, NO_ARCHIVES_IN_SCOPE, copyText, copyWithLink, @@ -20,6 +22,12 @@ import { // accent wears ONE colour — its homepage card's — on its card, its chip and its // results; /ask searches only the archives the chips leave in, and with every // archive switched off it says so and cannot ask. +// Release 11 (O1): /ask says how many of the hub's archives it searches +// (ASK_SCOPE_LINE), and is not ready until the hub's own list is in — the +// archives a browser added are known at once, /hub-sites.json's a moment +// later; a member whose live chat cannot be read says so on its chip +// (LIVE_CHAT_MISSING), with a Retry for the live chat alone that keeps its +// videos in the search while it runs. const ORIGIN_A = "http://localhost:4598"; const ORIGIN_B = "http://localhost:4599"; @@ -76,17 +84,33 @@ function pageOf(m: Member) { // Every request to a member origin is counted; `pages` decides how its // summaries page is answered, `subs` whether it has a subs manifest at all -// ("missing": a 404) or cannot serve it ("error": a 500). +// ("missing": a 404), cannot serve it ("error": a 500) or answers only when +// the test says so ("hold": parked in `heldSubs`). type PageMode = "ok" | "abort" | "hold"; type MemberMock = { requests: string[]; pages: PageMode; - subs: "ok" | "missing" | "error"; + subs: "ok" | "missing" | "error" | "hold"; held: Route[]; + heldSubs: Route[]; +}; + +const SUBS_OK = { + version: 4, + channels: [], + totalCount: 0, + liveChatTotalCount: 0, + generatedAt: "2026-01-01T00:00:00.000Z", }; async function mockMember(page: Page, m: Member): Promise<MemberMock> { - const mock: MemberMock = { requests: [], pages: "ok", subs: "ok", held: [] }; + const mock: MemberMock = { + requests: [], + pages: "ok", + subs: "ok", + held: [], + heldSubs: [], + }; await page.route(`${m.origin}/**`, async (route) => { const url = new URL(route.request().url()); mock.requests.push(url.pathname); @@ -104,14 +128,12 @@ async function mockMember(page: Page, m: Member): Promise<MemberMock> { if (url.pathname === "/subs/manifest.json" && mock.subs === "error") { return route.fulfill({ status: 500, headers: CORS, body: "" }); } + if (url.pathname === "/subs/manifest.json" && mock.subs === "hold") { + mock.heldSubs.push(route); + return; + } if (url.pathname === "/subs/manifest.json" && mock.subs === "ok") { - return fulfillJson(route, { - version: 4, - channels: [], - totalCount: 0, - liveChatTotalCount: 0, - generatedAt: "2026-01-01T00:00:00.000Z", - }); + return fulfillJson(route, SUBS_OK); } // No posts, no aliases (and, when `subs` is "missing", no live chat): a // clean 404 (a member without that corpus is not a failure). @@ -398,6 +420,21 @@ test.describe("hub federated search — scope, per-archive state, attribution", expect(mocks.a.requests).toContain("/summaries/page-0000.json"); expect(mocks.b.requests).toEqual([]); + // And it says so: one line, its [bracketed] words a link to the chips. + const scope = page.getByTestId("ask-scope"); + const oneOfTwo = ASK_SCOPE_LINE(1, 2); + if (oneOfTwo === null) { + await expect(scope).toHaveCount(0); + } else { + await expect(scope).toHaveText(copyText(oneOfTwo)); + const { link } = copyWithLink(oneOfTwo); + if (link) { + await expect( + scope.getByRole("link", { name: link, exact: true }), + ).toHaveAttribute("href", "/"); + } + } + // Back on (on the front page), /ask reads it again. await page.goto("/"); await chip(page, B).getByRole("button", { name: /Origin B/ }).click(); @@ -409,6 +446,75 @@ test.describe("hub federated search — scope, per-archive state, attribution", .toContain("/summaries/manifest.json"); await expect(ready).toBeVisible(); expect(mocks.b.requests).toContain("/summaries/page-0000.json"); + const twoOfTwo = ASK_SCOPE_LINE(2, 2); + if (twoOfTwo === null) await expect(scope).toHaveCount(0); + else await expect(scope).toHaveText(copyText(twoOfTwo)); + }); + + test("/ask is not ready until the hub's own list is in, even with an added archive ready", async ({ + page, + }) => { + // Origin B is an archive this browser added (read from storage at once); + // Origin A is the hub's own, from /hub-sites.json — held here. + const mocks = await setup(page, [A]); + const heldList: Route[] = []; + await page.route("**/hub-sites.json", (r) => { + heldList.push(r); + }); + await page.addInitScript((origin) => { + window.localStorage.setItem( + "ytdlp-tb:hub-sites", + JSON.stringify([ + { + origin, + siteId: "added-b", + siteTitle: "Origin B", + pwa: false, + kind: "external", + contract: 1, + addedAt: 1, + }, + ]), + ); + }, B.origin); + + await page.goto("/ask"); + // Origin B is fetched meanwhile — every feed of it answered... + await expect + .poll(() => mocks.b.requests) + .toEqual( + expect.arrayContaining([ + "/summaries/manifest.json", + "/summaries/page-0000.json", + "/subs/manifest.json", + "/posts/manifest.json", + ]), + ); + await expect.poll(() => heldList.length).toBeGreaterThan(0); + // ...but it is not the hub: no question may ground in it alone. Give a + // ready provider time to show itself, then check it did not. + await page.waitForTimeout(1_000); + await expect(page.getByPlaceholder("Loading transcripts…")).toBeVisible(); + await expect(page.getByTestId("ask-blocked")).toHaveCount(0); + await expect(page.getByTestId("ask-scope")).toHaveCount(0); + + // The hub's list arrives: Origin A is fetched, and the chat is ready over + // both. + for (const r of heldList.splice(0)) { + await fulfillJson(r, [ + { siteId: "origin0", siteTitle: A.title, siteUrl: A.origin, pwa: false, contract: 1 }, + ]); + } + await expect + .poll(() => mocks.a.requests) + .toContain("/summaries/page-0000.json"); + await expect( + page.getByPlaceholder(/^Ask about the transcripts/), + ).toBeVisible(); + const both = ASK_SCOPE_LINE(2, 2); + if (both !== null) { + await expect(page.getByTestId("ask-scope")).toHaveText(copyText(both)); + } }); test("a subs manifest that cannot be read is retried once and never fails its archive", async ({ @@ -425,7 +531,7 @@ test.describe("hub federated search — scope, per-archive state, attribution", await expect(resultFrom(page, B)).toHaveCount(1); await expect(resultFrom(page, A)).toHaveCount(1); await expect( - chip(page, B).getByRole("button", { name: "Retry Origin B" }), + chip(page, B).getByRole("button", { name: "Retry Origin B", exact: true }), ).toHaveCount(0); await expect(status(page)).toHaveCount(0); // Ready waits for the retry to settle, so the count is final here. @@ -434,6 +540,48 @@ test.describe("hub federated search — scope, per-archive state, attribution", ).toBe(2); }); + test("a member's missing live chat is noted on its chip, and its Retry refetches the live chat alone", async ({ + page, + }) => { + const mocks = await setup(page); + mocks.b.subs = "error"; + await page.goto("/"); + await expect(chip(page, B)).toHaveAttribute("data-status", "ready"); + await expect(resultFrom(page, B)).toHaveCount(1); + + // The note, on Origin B's chip only, with its own Retry. + const note = chip(page, B).getByTestId("hub-scope-live-chat"); + await expect(note).toHaveText(LIVE_CHAT_MISSING); + await expect(chip(page, A).getByTestId("hub-scope-live-chat")).toHaveCount(0); + const retryChat = chip(page, B).getByRole("button", { + name: "Retry live chat from Origin B", + exact: true, + }); + await expect(retryChat).toBeEnabled(); + + // Retry, answered only when the test says: while it is in flight Origin B + // stays ready and its video stays in the results, the note stays with its + // Retry disabled, and nothing but the live chat is asked for again. + mocks.b.subs = "hold"; + const asked = mocks.b.requests.length; + await retryChat.click(); + await expect.poll(() => mocks.b.heldSubs.length).toBe(1); + await expect(retryChat).toBeDisabled(); + await expect(chip(page, B)).toHaveAttribute("data-status", "ready"); + await expect(note).toHaveText(LIVE_CHAT_MISSING); + await expect(resultFrom(page, B)).toHaveCount(1); + await expect(resultFrom(page, A)).toHaveCount(1); + expect(mocks.b.requests.slice(asked)).toEqual(["/subs/manifest.json"]); + + // It answers: the note and its Retry go; the archive never left. + for (const r of mocks.b.heldSubs.splice(0)) await fulfillJson(r, SUBS_OK); + await expect(note).toHaveCount(0); + await expect(retryChat).toHaveCount(0); + await expect(chip(page, B)).toHaveAttribute("data-status", "ready"); + await expect(resultFrom(page, B)).toHaveCount(1); + expect(mocks.b.requests.slice(asked)).toEqual(["/subs/manifest.json"]); + }); + test("/ask with every archive switched off says so and cannot ask; with one back on, it asks", async ({ page, }) => {