Archilyzer · Source

archilyzer

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

commit d336209bb68118450b4680d77f39dd6da3b2310b
parent fa5e654fb90ee44ad213c11827a4017d6a50ead1
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun, 24 May 2026 13:31:52 -0400

unlisted bucket

Diffstat:
Mcommon/components/TranscriptSearch.tsx | 44+++++++++++++++++++++++++++++++++++++++++---
Mcommon/components/exportFilterStorage.ts | 1+
Mcommon/components/shareUrl.ts | 4++++
Mcommon/components/urlState.ts | 7+++++++
Mcommon/controller/buildIndex.ts | 36++++++++++++++++++++++--------------
Mcommon/lib/transcripts-server.ts | 3++-
Mcommon/lib/transcripts.ts | 1+
Meditor/CHANGELOG.md | 1+
Meditor/app/channels/[slug]/components/stages/DiagnosticsStage.tsx | 1+
Meditor/e2e/export-search.spec.ts | 59++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mexport/CHANGELOG.md | 3+++
Mexport/e2e/fixtures/data.ts | 1+
Mexport/e2e/fixtures/many-results.ts | 1+
13 files changed, 143 insertions(+), 19 deletions(-)

diff --git a/common/components/TranscriptSearch.tsx b/common/components/TranscriptSearch.tsx @@ -94,6 +94,7 @@ const FILTER_URL_KEYS = [ "nar", "nav", "nd", + "nu", "m", "tk", ] as const; @@ -173,6 +174,7 @@ export default function TranscriptSearch() { const [committedNar, setCommittedNar] = useState(false); const [committedNav, setCommittedNav] = useState(false); const [committedNd, setCommittedNd] = useState(false); + const [committedNu, setCommittedNu] = useState(false); const committedChannelsKey = useMemo( () => Array.from(committedExcludedChannels).sort().join(" "), [committedExcludedChannels], @@ -200,6 +202,7 @@ export default function TranscriptSearch() { const [draftNar, setDraftNar] = useState(false); const [draftNav, setDraftNav] = useState(false); const [draftNd, setDraftNd] = useState(false); + const [draftNu, setDraftNu] = useState(false); const [hydrated, setHydrated] = useState(false); const [profiles, setProfiles] = useState<Record<string, FilterSnapshot>>({}); @@ -436,7 +439,16 @@ export default function TranscriptSearch() { if (committedChannels.has(t.channel)) return false; if (t.isLivestream ? committedNol : committedNov) return false; if (t.ageRestricted ? committedNar : committedNaa) return false; - if (t.isDeleted ? committedNd : committedNav) return false; + // Availability is a three-way split: deleted / unlisted / available. + // Unlisted has its own bucket, so excluding "Available" no longer hides + // unlisted videos. + if (t.isDeleted) { + if (committedNd) return false; + } else if (t.isUnlisted) { + if (committedNu) return false; + } else if (committedNav) { + return false; + } return true; }; }, [ @@ -447,9 +459,10 @@ export default function TranscriptSearch() { committedNar, committedNav, committedNd, + committedNu, ]); - const filterKey = `${committedChannelsKey}|${committedNov ? 1 : 0}|${committedNol ? 1 : 0}|${committedNaa ? 1 : 0}|${committedNar ? 1 : 0}|${committedNav ? 1 : 0}|${committedNd ? 1 : 0}`; + const filterKey = `${committedChannelsKey}|${committedNov ? 1 : 0}|${committedNol ? 1 : 0}|${committedNaa ? 1 : 0}|${committedNar ? 1 : 0}|${committedNav ? 1 : 0}|${committedNd ? 1 : 0}|${committedNu ? 1 : 0}`; const globalScopeSlugs = useMemo<string[]>(() => { if (!transcripts) return []; @@ -579,7 +592,8 @@ export default function TranscriptSearch() { draftNaa !== committedNaa || draftNar !== committedNar || draftNav !== committedNav || - draftNd !== committedNd; + draftNd !== committedNd || + draftNu !== committedNu; const committedSnapshot = useMemo<FilterSnapshot>(() => { const deltas = buildSnapshotDeltas( @@ -594,6 +608,7 @@ export default function TranscriptSearch() { if (committedNar) snap.nar = true; if (committedNav) snap.nav = true; if (committedNd) snap.nd = true; + if (committedNu) snap.nu = true; snap.query = stringifyRoot(committedRoot); return snap; }, [ @@ -606,6 +621,7 @@ export default function TranscriptSearch() { committedNar, committedNav, committedNd, + committedNu, committedRoot, ]); @@ -623,6 +639,7 @@ export default function TranscriptSearch() { if (draftNar) snap.nar = true; if (draftNav) snap.nav = true; if (draftNd) snap.nd = true; + if (draftNu) snap.nu = true; snap.query = stringifyRoot(draftRoot); return snap; }, [ @@ -635,6 +652,7 @@ export default function TranscriptSearch() { draftNar, draftNav, draftNd, + draftNu, draftRoot, ]); @@ -646,6 +664,7 @@ export default function TranscriptSearch() { setCommittedNar(draftNar); setCommittedNav(draftNav); setCommittedNd(draftNd); + setCommittedNu(draftNu); stripAllFilterParamsFromUrl(); }, [ draftExcludedChannels, @@ -655,6 +674,7 @@ export default function TranscriptSearch() { draftNar, draftNav, draftNd, + draftNu, ]); // ─── Hydration ──────────────────────────────────────────────────────────── @@ -696,6 +716,7 @@ export default function TranscriptSearch() { let initialNar = false; let initialNav = false; let initialNd = false; + let initialNu = false; if (hasShareV1(search)) { const sel = parseShareV1(search, channelOptions); @@ -710,6 +731,7 @@ export default function TranscriptSearch() { initialNar = !sel.restricted; initialNav = !sel.available; initialNd = !sel.deleted; + initialNu = !sel.unlisted; } else if (urlHasAnyFilterParam()) { const excluded = new Set(params.getAll("ch")); initialExcluded = excluded; @@ -719,6 +741,7 @@ export default function TranscriptSearch() { initialNar = params.get("nar") === "1"; initialNav = params.get("nav") === "1"; initialNd = params.get("nd") === "1"; + initialNu = params.get("nu") === "1"; } let resolvedRoot = rootFromUrl; @@ -740,6 +763,7 @@ export default function TranscriptSearch() { initialNar = snapshot?.nar === true; initialNav = snapshot?.nav === true; initialNd = snapshot?.nd === true; + initialNu = snapshot?.nu === true; if (!resolvedRoot && snapshot?.query) { resolvedRoot = parseRoot(snapshot.query); } @@ -758,6 +782,7 @@ export default function TranscriptSearch() { setDraftNar(initialNar); setDraftNav(initialNav); setDraftNd(initialNd); + setDraftNu(initialNu); setCommittedExcludedChannels(initialExcluded); setCommittedNov(initialNov); setCommittedNol(initialNol); @@ -765,6 +790,7 @@ export default function TranscriptSearch() { setCommittedNar(initialNar); setCommittedNav(initialNav); setCommittedNd(initialNd); + setCommittedNu(initialNu); setHydrated(true); // eslint-disable-next-line react-hooks/exhaustive-deps }, [channelOptions.length === 0, hydrated]); @@ -867,6 +893,7 @@ export default function TranscriptSearch() { setDraftNar(snapshot?.nar === true); setDraftNav(snapshot?.nav === true); setDraftNd(snapshot?.nd === true); + setDraftNu(snapshot?.nu === true); if (snapshot?.query) { const parsed = parseRoot(snapshot.query); if (parsed) setDraftRoot(parsed); @@ -890,6 +917,7 @@ export default function TranscriptSearch() { setCommittedNar(snapshot?.nar === true); setCommittedNav(snapshot?.nav === true); setCommittedNd(snapshot?.nd === true); + setCommittedNu(snapshot?.nu === true); if (snapshot?.query) { const parsed = parseRoot(snapshot.query); if (parsed) setCommittedRoot(parsed); @@ -1068,6 +1096,7 @@ export default function TranscriptSearch() { restricted: !committedNar, available: !committedNav, deleted: !committedNd, + unlisted: !committedNu, tracks: new Set(), }; const params = buildShareSearchParams(selection, { @@ -1444,6 +1473,15 @@ export default function TranscriptSearch() { <label className="flex items-center gap-1.5 select-none"> <input type="checkbox" + checked={!draftNu} + onChange={(e) => setDraftNu(!e.target.checked)} + className="accent-blue-600" + /> + Unlisted + </label> + <label className="flex items-center gap-1.5 select-none"> + <input + type="checkbox" checked={!draftNd} onChange={(e) => setDraftNd(!e.target.checked)} className="accent-blue-600" diff --git a/common/components/exportFilterStorage.ts b/common/components/exportFilterStorage.ts @@ -26,6 +26,7 @@ export type FilterSnapshot = { nar?: boolean; nav?: boolean; nd?: boolean; + nu?: boolean; mode?: SearchMode; tracks?: string[]; query?: string; diff --git a/common/components/shareUrl.ts b/common/components/shareUrl.ts @@ -15,6 +15,7 @@ export type ShareSelection = { allAges: boolean; restricted: boolean; available: boolean; + unlisted: boolean; deleted: boolean; tracks: Set<string>; }; @@ -30,6 +31,7 @@ export const FILTER_URL_KEYS_LEGACY = [ "nar", "nav", "nd", + "nu", "tk", ] as const; @@ -71,6 +73,7 @@ export function parseShareV1( allAges: audience.has("a"), restricted: audience.has("r"), available: availability.has("a"), + unlisted: availability.has("u"), deleted: availability.has("d"), tracks, }; @@ -89,6 +92,7 @@ export function buildShareSearchParams( if (committed.allAges) p.append("fa", "a"); if (committed.restricted) p.append("fa", "r"); if (committed.available) p.append("fav", "a"); + if (committed.unlisted) p.append("fav", "u"); if (committed.deleted) p.append("fav", "d"); for (const tk of Array.from(committed.tracks).sort()) p.append("fk", tk); if (opts.q) p.set("q", opts.q); diff --git a/common/components/urlState.ts b/common/components/urlState.ts @@ -22,6 +22,7 @@ export type UrlParams = { nar: boolean; nav: boolean; nd: boolean; + nu: boolean; mode: SearchMode; // Subs-mode track exclusion filter; empty array means "all tracks" (no // exclusions). Mirrors the channel-exclusion convention. Persisted on the @@ -70,6 +71,7 @@ function parse(search: string): UrlParams { nar: p.get("nar") === "1", nav: p.get("nav") === "1", nd: p.get("nd") === "1", + nu: p.get("nu") === "1", mode, tracks: p.getAll("tk"), }; @@ -93,6 +95,7 @@ type Patch = Partial<{ nar: boolean; nav: boolean; nd: boolean; + nu: boolean; mode: SearchMode; tracks: string[]; }>; @@ -147,6 +150,10 @@ export function writeUrlParams(patch: Patch) { if (patch.nd) params.set("nd", "1"); else params.delete("nd"); } + if (patch.nu !== undefined) { + if (patch.nu) params.set("nu", "1"); + else params.delete("nu"); + } if (patch.mode !== undefined) { if (patch.mode === "subs") params.set("m", "subs"); else params.delete("m"); diff --git a/common/controller/buildIndex.ts b/common/controller/buildIndex.ts @@ -82,7 +82,7 @@ import { import { loadAvailability } from "../lib/availability-server"; import { AVAILABILITY_FILENAME } from "../lib/availability"; -const SCHEMA_VERSION = 8; +const SCHEMA_VERSION = 9; type IndexKey = [string, string, string]; type ChannelKey = [string, string, string]; @@ -95,6 +95,7 @@ type MtimeRecord = { subsMs: number | null; availabilityMs: number | null; isDeleted: boolean; + isUnlisted: boolean; indexKey: IndexKey; }; @@ -654,9 +655,11 @@ export async function buildIndex({ else subs.remove(indexKey); let isDeleted = false; + let isUnlisted = false; if (s.availabilityMs !== null) { const availability = await loadAvailability(videoFullDir); isDeleted = availability?.availability === "deleted"; + isUnlisted = availability?.availability === "unlisted"; } byChannel.put(indexToChannelKey(indexKey), 1); @@ -666,6 +669,7 @@ export async function buildIndex({ subsMs: s.subsMs, availabilityMs: s.availabilityMs, isDeleted, + isUnlisted, indexKey, }); } catch (err) { @@ -918,17 +922,21 @@ export async function buildIndex({ `Transcript pages: ${pagesWritten} written, ${pagesSkipped} unchanged, ${pagesDeleted} stale removed.`, ); - // Build a lookup from indexKey → isDeleted from MtimeRecord, which caches the - // availability check done at mutation-processing time. Previously this loop - // read availability.json for every video on every build (~28k sequential - // disk reads), which stalled GC right before the heaviest phase. + // Build lookups from indexKey → isDeleted / isUnlisted from MtimeRecord, which + // caches the availability check done at mutation-processing time. Previously + // this loop read availability.json for every video on every build (~28k + // sequential disk reads), which stalled GC right before the heaviest phase. const deletedByIndexKey = new Set<string>(); + const unlistedByIndexKey = new Set<string>(); for (const { value } of mtimes.getRange()) { const rec = value as MtimeRecord; + const ik = rec.indexKey; if (rec.isDeleted) { - const ik = rec.indexKey; deletedByIndexKey.add(`${ik[0]}\x00${ik[1]}\x00${ik[2]}`); } + if (rec.isUnlisted) { + unlistedByIndexKey.add(`${ik[0]}\x00${ik[1]}\x00${ik[2]}`); + } } let subsPagesWritten = 0; @@ -968,10 +976,10 @@ export async function buildIndex({ if (!stored || stored.length === 0) continue; const summary = sums.get(indexKey); if (!summary) continue; - const isDeleted = deletedByIndexKey.has( - `${indexKey[0]}\x00${indexKey[1]}\x00${indexKey[2]}`, - ); - const display = toDisplaySummary(summary, { isDeleted }); + const subsKey = `${indexKey[0]}\x00${indexKey[1]}\x00${indexKey[2]}`; + const isDeleted = deletedByIndexKey.has(subsKey); + const isUnlisted = unlistedByIndexKey.has(subsKey); + const display = toDisplaySummary(summary, { isDeleted, isUnlisted }); const tracks: Record<string, Cue[]> = {}; let hasLiveChat = false; for (const t of stored) { @@ -1110,10 +1118,10 @@ export async function buildIndex({ if (s.channel) { channelCounts.set(s.channel, (channelCounts.get(s.channel) ?? 0) + 1); } - const isDeleted = deletedByIndexKey.has( - `${ik[0]}\x00${ik[1]}\x00${ik[2]}`, - ); - buffer.push(toDisplaySummary(s, { isDeleted })); + const summaryKey = `${ik[0]}\x00${ik[1]}\x00${ik[2]}`; + const isDeleted = deletedByIndexKey.has(summaryKey); + const isUnlisted = unlistedByIndexKey.has(summaryKey); + buffer.push(toDisplaySummary(s, { isDeleted, isUnlisted })); total++; if (buffer.length >= pageSize) await flushPage(); } diff --git a/common/lib/transcripts-server.ts b/common/lib/transcripts-server.ts @@ -69,7 +69,7 @@ export function summarize( export function toDisplaySummary( t: TranscriptSummary, - extras: { isDeleted?: boolean } = {}, + extras: { isDeleted?: boolean; isUnlisted?: boolean } = {}, ): DisplaySummary { return { slug: t.slug, @@ -83,6 +83,7 @@ export function toDisplaySummary( isLivestream: t.isLivestream, ageRestricted: t.ageRestricted, isDeleted: extras.isDeleted === true, + isUnlisted: extras.isUnlisted === true, platform: t.platform, webpageUrl: t.webpageUrl, }; diff --git a/common/lib/transcripts.ts b/common/lib/transcripts.ts @@ -33,6 +33,7 @@ export type DisplaySummary = { isLivestream: boolean; ageRestricted: boolean; isDeleted: boolean; + isUnlisted: boolean; platform: Platform; webpageUrl: string; }; diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **Unlisted videos now get their own clickable list in the channel availability diagnostics**, alongside Deleted/Private/Members-only/Needs-auth/Error. Previously unlisted videos were only shown as a count, so there was no way to jump to the specific videos. - **Cutting a release now clears `## [Unreleased]` entirely** instead of leaving an empty heading sitting above the new dated section. The `/changelog` and `/deploy` pages stop showing a blank Unreleased block after a release; the heading reappears the next time somebody hand-adds a pending bullet to the file. ## [0.2.0] - 2026-05-22 diff --git a/editor/app/channels/[slug]/components/stages/DiagnosticsStage.tsx b/editor/app/channels/[slug]/components/stages/DiagnosticsStage.tsx @@ -406,6 +406,7 @@ const AVAILABILITY_LABELS: Record<Availability, string> = { // counts only to keep the page from drowning in healthy videos. const AVAILABILITY_LIST_STATUSES: Availability[] = [ "deleted", + "unlisted", "private", "members_only", "needs_auth", diff --git a/editor/e2e/export-search.spec.ts b/editor/e2e/export-search.spec.ts @@ -14,6 +14,7 @@ type Summary = { isLivestream: boolean; ageRestricted: boolean; isDeleted: boolean; + isUnlisted: boolean; platform: "youtube" | "rumble" | "odysee"; webpageUrl: string; }; @@ -32,9 +33,17 @@ const summaries: Summary[] = [ makeSummary("v-deleted-title", "Deleted platypus chronicles", true), // Deleted, title does NOT contain "platypus", transcript contains it makeSummary("v-deleted-cue", "Vanished video on aquatic life", true), + // Unlisted, title does NOT contain "platypus" (keeps the title-badge count + // stable), transcript contains it so it surfaces as a transcript match. + makeSummary("v-unlisted-cue", "Hidden mammal documentary", false, true), ]; -function makeSummary(id: string, title: string, isDeleted: boolean): Summary { +function makeSummary( + id: string, + title: string, + isDeleted: boolean, + isUnlisted = false, +): Summary { return { slug: `${CHANNEL_SLUG}/${id}`, id, @@ -47,6 +56,7 @@ function makeSummary(id: string, title: string, isDeleted: boolean): Summary { isLivestream: false, ageRestricted: false, isDeleted, + isUnlisted, platform: "youtube", webpageUrl: `https://example.com/${id}`, }; @@ -67,6 +77,9 @@ const transcripts: Record<string, { cues: { start: number; text: string }[] }> = "v-deleted-cue": { cues: [{ start: 45, text: "the platypus swims silently" }], }, + "v-unlisted-cue": { + cues: [{ start: 60, text: "an unlisted platypus appears" }], + }, }; async function installFixtureRoutes(page: Page) { @@ -321,6 +334,9 @@ test.describe("export TranscriptSearch — title matching + filter layout", () = page.getByRole("checkbox", { name: "Available" }), ).toBeChecked(); await expect( + page.getByRole("checkbox", { name: "Unlisted" }), + ).toBeChecked(); + await expect( page.getByRole("checkbox", { name: "Deleted" }), ).toBeChecked(); }); @@ -402,6 +418,47 @@ test.describe("export TranscriptSearch — title matching + filter layout", () = page.getByRole("button", { name: /All about the platypus/ }), ).toHaveCount(0); }); + + test("unlisted videos are shown by default and 'Unlisted' unchecked hides them", async ({ + page, + }) => { + await search(page, "platypus"); + const unlisted = page.getByRole("button", { + name: /Hidden mammal documentary.*Test Channel/, + }); + // Default: unlisted videos are included. + await expect(unlisted).toBeVisible(); + + await page.getByRole("checkbox", { name: "Unlisted" }).uncheck(); + await page.getByPlaceholder("Search transcripts...").press("Enter"); + + await expect(unlisted).toHaveCount(0); + // Available (public) matches are unaffected. + await expect( + page.getByRole("button", { + name: /Platypus facts and figures.*Test Channel/, + }), + ).toBeVisible(); + }); + + test("'Available' unchecked keeps unlisted videos (separate bucket)", async ({ + page, + }) => { + await search(page, "platypus"); + await page.getByRole("checkbox", { name: "Available" }).uncheck(); + await page.getByPlaceholder("Search transcripts...").press("Enter"); + + // Unlisted is its own bucket, so it survives excluding "Available". + await expect( + page.getByRole("button", { + name: /Hidden mammal documentary.*Test Channel/, + }), + ).toBeVisible(); + // Public videos drop out. + await expect( + page.getByRole("button", { name: /Platypus facts and figures/ }), + ).toHaveCount(0); + }); }); test.describe("export TranscriptSearch — live chat tab", () => { diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## [Unreleased] +- **"Unlisted" availability filter.** The Availability filter group now has a dedicated **Unlisted** checkbox alongside Available and Deleted. Availability is now a three-way split — unlisted videos are their own bucket rather than counting as "Available", so you can isolate or hide them independently. Unlisted videos remain shown by default, and the filter is saved in profiles and shareable links like the other filters. + ## [0.2.0] - 2026-05-22 - **Composable layered search.** The single search bar is now a query builder: combine any number of layers with AND / OR / NOT and arbitrary nesting, each layer scoped to transcripts, live chat, or title/channel. Matches from every layer surface in the result list with a per-layer colour swatch. Editing one layer doesn't re-run the others, so deep nests stay fast. Simple one-keyword search still looks like a single input — the builder collapses to compact mode when there's only one layer. Legacy `?q=&m=&re=` share-links auto-migrate to a one-layer tree. - **Query-builder controls.** A "Reset layers" button collapses the builder back to one empty transcripts leaf. An "Unwrap" button on every non-root group flattens its children into the parent; if the group had NOT set, the promoted children inherit a flipped NOT so the meaning of the subtree doesn't silently change. The "slow / medium / fast" cost badges next to each scope dropdown have been removed — they read as a warning on the primary mode (transcripts) without giving you anything to do about it. diff --git a/export/e2e/fixtures/data.ts b/export/e2e/fixtures/data.ts @@ -26,6 +26,7 @@ function makeSummary(id: string, title: string) { isLivestream: false, ageRestricted: false, isDeleted: false, + isUnlisted: false, platform: "rumble" as const, webpageUrl: `https://example.com/${id}`, }; diff --git a/export/e2e/fixtures/many-results.ts b/export/e2e/fixtures/many-results.ts @@ -37,6 +37,7 @@ function makeSummary(i: number) { isLivestream: false, ageRestricted: false, isDeleted: false, + isUnlisted: false, platform: "rumble" as const, webpageUrl: `https://example.com/${videoId(i)}`, };