Archilyzer · Source

archilyzer

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

commit 63f4cfd9dcbb68e094cbdfd7de54f8c122fdd794
parent 088ccc391c38445f6ad69b91f54ef653284b1e55
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun, 28 Jun 2026 23:05:47 -0400

Merge feat/hub-masthead-header: shrink-on-scroll wordmark masthead

Diffstat:
Mcommon/controller/autoRunner.ts | 31++++++++++++-------------------
Mcommon/jobs/autoQueuePolicy.test.ts | 74+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcommon/jobs/autoQueuePolicy.ts | 46+++++++++++++++++++++++++++++++---------------
Meditor/CHANGELOG.md | 1+
Meditor/app/auto-queue/components/PolicyTreeEditor.tsx | 2+-
Meditor/app/auto-queue/page.tsx | 9+++++----
Mhomepage/CHANGELOG.md | 4++++
Mhomepage/app/components/Header.tsx | 18++++--------------
Ahomepage/app/components/SiteHeader.tsx | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mhomepage/app/page.tsx | 16+++-------------
10 files changed, 198 insertions(+), 69 deletions(-)

diff --git a/common/controller/autoRunner.ts b/common/controller/autoRunner.ts @@ -17,8 +17,7 @@ import { type WorkPick, buildPendingByLeaf, selectNextWork, - DEFAULT_TRANSCRIBE_BUCKET, - DEFAULT_DOWNLOAD_BUCKET, + bucketsForKind, } from "../jobs/autoQueuePolicy"; import { type AutoQueueKind, @@ -150,8 +149,6 @@ async function listChannelMeta(paths: Paths): Promise<ChannelMeta[]> { return out; } -const BUCKET_NAMES = ["downloadedNoTranscript", "failedListed"] as const; - // Read each channel's snapshot and project the buckets this runner kind cares // about into ChannelWork, plus a videoId -> owning channel map (a platform/all // leaf spans channels, so the pick needs the owner to locate the video dir). @@ -166,15 +163,14 @@ async function buildChannelWork( const snap = await readChannelSnapshot(paths, slug); if (!snap) continue; const buckets: Record<string, string[]> = {}; - if (kind === "transcription") { - for (const name of BUCKET_NAMES) { - const ids = snap.buckets?.[name] ?? []; - buckets[name] = ids; - for (const id of ids) if (!owner.has(id)) owner.set(id, slug); - } - } else { - const ids = snap.undownloadedIds ?? []; - buckets[DEFAULT_DOWNLOAD_BUCKET] = ids; + for (const name of bucketsForKind(kind)) { + // `undownloadedIds` lives at the snapshot top level; every other bucket + // is under snap.buckets. + const ids = + name === "undownloadedIds" + ? (snap.undownloadedIds ?? []) + : ((snap.buckets as Record<string, string[]>)?.[name] ?? []); + buckets[name] = ids; for (const id of ids) if (!owner.has(id)) owner.set(id, slug); } channels.push({ slug, platform, buckets }); @@ -190,11 +186,9 @@ export async function computeLeafPendingCounts( paths: Paths = getPaths(), ): Promise<Record<string, number>> { const policy = getSettings().autoQueue[kind]; - const defaultBucket = - kind === "transcription" ? DEFAULT_TRANSCRIBE_BUCKET : DEFAULT_DOWNLOAD_BUCKET; const meta = await listChannelMeta(paths); const { channels } = await buildChannelWork(paths, kind, meta); - const pending = buildPendingByLeaf(policy.root, channels, defaultBucket); + const pending = buildPendingByLeaf(policy.root, channels, bucketsForKind(kind)); const counts: Record<string, number> = {}; for (const [leafId, ids] of Object.entries(pending)) counts[leafId] = ids.length; return counts; @@ -233,8 +227,7 @@ async function runLoop( signal: AbortSignal, ctx: JobRunContext, ): Promise<void> { - const defaultBucket = - kind === "transcription" ? DEFAULT_TRANSCRIBE_BUCKET : DEFAULT_DOWNLOAD_BUCKET; + const defaultBuckets = bucketsForKind(kind); const tracker = makeTaskTracker(ctx, onLog); const state = await readAutoQueueState(paths); const kindState = state[kind]; @@ -345,7 +338,7 @@ async function runLoop( metaCache.map((m) => [m.slug, m.platform ?? "unknown"]), ); const { channels, owner } = await buildChannelWork(paths, kind, metaCache); - const pending = buildPendingByLeaf(policy.root, channels, defaultBucket); + const pending = buildPendingByLeaf(policy.root, channels, defaultBuckets); const exclude = new Set<string>([...live.inFlight.keys(), ...completed]); removeIds(pending, exclude); // Download only: drop videos whose platform already has an in-flight diff --git a/common/jobs/autoQueuePolicy.test.ts b/common/jobs/autoQueuePolicy.test.ts @@ -5,6 +5,7 @@ import { type AutoQueueRuntime, type ChannelWork, buildPendingByLeaf, + bucketsForKind, defaultAutoQueue, emptyAutoQueueRuntime, flattenLeaves, @@ -219,7 +220,7 @@ test("buildPendingByLeaf: channel + platform + all matchers", () => { { id: "rest", match: { type: "all" } }, ], }; - const pending = buildPendingByLeaf(root, CHANNELS, "downloadedNoTranscript"); + const pending = buildPendingByLeaf(root, CHANNELS, ["downloadedNoTranscript"]); assert.deepEqual(pending.corn, ["c1", "c2"]); assert.deepEqual(pending.tw, ["h1"]); // hasanabi is twitch assert.deepEqual(pending.rest, []); // both channels already claimed @@ -234,7 +235,7 @@ test("buildPendingByLeaf: first-match-wins prevents double-claiming a video", () { id: "corn", match: { type: "channel", value: "cornbreadman" } }, ], }; - const pending = buildPendingByLeaf(root, CHANNELS, "downloadedNoTranscript"); + const pending = buildPendingByLeaf(root, CHANNELS, ["downloadedNoTranscript"]); assert.deepEqual(pending.all1.sort(), ["c1", "c2", "h1"]); assert.deepEqual(pending.corn, []); // already claimed by the catch-all above it }); @@ -251,11 +252,78 @@ test("buildPendingByLeaf: a bucket leaf draws from its named bucket", () => { { id: "corn", match: { type: "channel", value: "cornbreadman" } }, ], }; - const pending = buildPendingByLeaf(root, CHANNELS, "downloadedNoTranscript"); + const pending = buildPendingByLeaf(root, CHANNELS, ["downloadedNoTranscript"]); assert.deepEqual(pending.retry, ["cf1"]); assert.deepEqual(pending.corn, ["c1", "c2"]); }); +test("buildPendingByLeaf: a bucketless leaf unions all default buckets, deduped", () => { + const root: AutoQueueGroup = { + id: "root", + mode: "strict", + children: [{ id: "corn", match: { type: "channel", value: "cornbreadman" } }], + }; + // cornbreadman has c1,c2 in downloadedNoTranscript and cf1 in failedListed; a + // bucketless leaf draws from both. + const pending = buildPendingByLeaf(root, CHANNELS, [ + "downloadedNoTranscript", + "failedListed", + ]); + assert.deepEqual(pending.corn, ["c1", "c2", "cf1"]); +}); + +test("buildPendingByLeaf: union follows default-bucket list order", () => { + const channels: ChannelWork[] = [ + { + slug: "cornbreadman", + platform: "odysee", + // a4 is in BOTH buckets — it must be taken once, under the earlier bucket. + buckets: { partialDownloads: ["p1", "a4"], undownloadedIds: ["a4", "u1"] }, + }, + ]; + const root: AutoQueueGroup = { + id: "root", + mode: "strict", + children: [{ id: "corn", match: { type: "channel", value: "cornbreadman" } }], + }; + // Partials first (DOWNLOAD_BUCKETS order), then the fresh ids, deduped. + const pending = buildPendingByLeaf(root, channels, [ + "partialDownloads", + "undownloadedIds", + ]); + assert.deepEqual(pending.corn, ["p1", "a4", "u1"]); +}); + +test("buildPendingByLeaf: partial-only channel is still picked (regression)", () => { + // The cornbreadman bug: remaining work drifted entirely into partialDownloads, + // leaving undownloadedIds empty — the old single-bucket default skipped it. + const channels: ChannelWork[] = [ + { + slug: "cornbreadman", + platform: "odysee", + buckets: { undownloadedIds: [], partialDownloads: ["p1", "p2"] }, + }, + ]; + const root: AutoQueueGroup = { + id: "root", + mode: "strict", + children: [{ id: "corn", match: { type: "channel", value: "cornbreadman" } }], + }; + const pending = buildPendingByLeaf(root, channels, bucketsForKind("download")); + assert.deepEqual(pending.corn, ["p1", "p2"]); +}); + +test("bucketsForKind: per-kind ordered bucket lists", () => { + assert.deepEqual( + [...bucketsForKind("transcription")], + ["downloadedNoTranscript", "failedListed"], + ); + assert.deepEqual( + [...bucketsForKind("download")], + ["partialDownloads", "undownloadedIds"], + ); +}); + test("flattenLeaves: pre-order priority order", () => { const root: AutoQueueGroup = { id: "root", diff --git a/common/jobs/autoQueuePolicy.ts b/common/jobs/autoQueuePolicy.ts @@ -76,10 +76,20 @@ export type AutoQueueSettings = { download: AutoQueuePolicy; }; -// Default snapshot bucket each runner kind draws from when a leaf doesn't -// specify one. -export const DEFAULT_TRANSCRIBE_BUCKET = "downloadedNoTranscript"; -export const DEFAULT_DOWNLOAD_BUCKET = "undownloadedIds"; +// Buckets each runner kind draws from, in priority order. A leaf with no +// explicit bucket draws from the whole list (union, deduped); the list order is +// its internal priority. Single source of truth for the runner, the pending- +// count helper, and the editor's bucket picker. +export const TRANSCRIBE_BUCKETS = ["downloadedNoTranscript", "failedListed"] as const; +export const DOWNLOAD_BUCKETS = ["partialDownloads", "undownloadedIds"] as const; + +// Local kind type — do NOT import AutoQueueKind from autoQueueState.ts, which +// already imports from this module (the reverse edge would be a cycle). +export function bucketsForKind( + kind: "transcription" | "download", +): readonly string[] { + return kind === "transcription" ? TRANSCRIBE_BUCKETS : DOWNLOAD_BUCKETS; +} export const AUTO_QUEUE_MAX_WORKERS_MAX = 64; @@ -135,26 +145,32 @@ function matchesChannel(match: AutoQueueMatch, ch: ChannelWork): boolean { // pre-order) leaf whose match covers the video's channel and whose bucket // contains it. First-match-wins prevents the same video being claimed — and thus // double-processed — by two overlapping leaves (e.g. a channel leaf and an `all` -// catch-all). Returns leafId -> available video ids (in their snapshot order). +// catch-all). A leaf with an explicit `match.bucket` draws from only that bucket; +// a leaf with none draws from the union of `defaultBuckets` (the kind's full +// list), in list order. The `claimed` Set dedups across AND within leaves, so a +// video present in two buckets is taken once, under the earlier (higher-priority) +// bucket. Returns leafId -> available video ids (in priority order). export function buildPendingByLeaf( root: AutoQueueNode, channels: ReadonlyArray<ChannelWork>, - defaultBucket: string, + defaultBuckets: ReadonlyArray<string>, ): Record<string, string[]> { const leaves = flattenLeaves(root); const pending: Record<string, string[]> = {}; for (const leaf of leaves) pending[leaf.id] = []; const claimed = new Set<string>(); for (const leaf of leaves) { - const bucket = leaf.match.bucket ?? defaultBucket; - for (const ch of channels) { - if (!matchesChannel(leaf.match, ch)) continue; - const ids = ch.buckets[bucket]; - if (!ids) continue; - for (const id of ids) { - if (claimed.has(id)) continue; - claimed.add(id); - pending[leaf.id].push(id); + const bucketNames = leaf.match.bucket ? [leaf.match.bucket] : defaultBuckets; + for (const bucket of bucketNames) { + for (const ch of channels) { + if (!matchesChannel(leaf.match, ch)) continue; + const ids = ch.buckets[bucket]; + if (!ids) continue; + for (const id of ids) { + if (claimed.has(id)) continue; + claimed.add(id); + pending[leaf.id].push(id); + } } } } diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **Auto-queue rules with no bucket now draw from *all* of a runner's buckets, and auto-download can resume partial downloads.** A policy-tree rule left at the **"all buckets (default)"** setting (previously just labeled *default*) now draws from the **union** of every bucket that runner kind tracks — deduped, in priority order — instead of only the single primary bucket. This fixes channels (e.g. an Odysee channel mid-download) that quietly stopped being auto-downloaded once their remaining work drifted entirely into **partially-downloaded** videos: those have a `.part` file but no completed audio, so they live in the `partialDownloads` bucket and were **absent from `undownloadedIds`** — the only bucket auto-download used to load. The download runner now loads `partialDownloads` alongside `undownloadedIds` (partials first, so in-progress downloads resume via `downloadOneManaged` before fresh ones start), and exposes `partialDownloads` as a selectable bucket in the policy editor so you can dedicate a high-priority rule to resuming partials. The per-kind bucket lists are consolidated behind a single `bucketsForKind` source of truth shared by the runner, the per-rule pending-count helper, and the editor's bucket picker (so they can't drift). Note: a *bucketless* auto-transcribe rule now also drains `failedListed` after `downloadedNoTranscript` (it already loaded both); platform rate-limit backoff is unchanged and remains an independent reason a throttled platform may pause. See `common/jobs/autoQueuePolicy.ts` (`buildPendingByLeaf` + `bucketsForKind` + unit tests), `common/controller/autoRunner.ts`, and `editor/app/auto-queue/{page.tsx,components/PolicyTreeEditor.tsx}`. - **Hub homepage redesigned into a cross-site landing; the homepage page-creator is removed.** The hub's home page is now a single mobile-first cross-site landing (headline KPIs and one stacked activity chart with Metric [Transcribed/Downloaded] · Breakdown [By site/By channel] · Bucket [Week/Month/Cumulative] · Range [90d/12mo/All] · Display [Share/Counts] controls, plus a metric-aware site-links grid with sparklines and a "#1 this month" badge), built from a small `homepage-summary.json` pre-computed by `compose-homepage`. The separate `/stats` dashboard route folds into it. Consequently the hub's **Markdown-pages subsystem is dropped**: **Manage → Homepage** now edits only branding (the Pages list, New-page, and the page editor are gone), and the homepage config no longer carries a `nav`. The page server actions (`saveHomepagePageAction`/`deleteHomepagePageAction`), `editor/app/homepage/pages/*`, `PageEditor.tsx`, `common/lib/{homepagePages,homepageConstants}.ts`, and `paths.homepagePagesDir` are removed. See `editor/app/homepage/{page.tsx,actions.ts}`, `common/bin/compose-homepage.ts`, `common/lib/{homepageSummary,homepageChart}.ts`, and the `homepage/` package. (Re-addable later if needed.) - **One-click Retry for failed jobs (plus "Retry all failed").** A failed job that carries a replay descriptor (any bookmarkable kind — sync, download-missing, transcribe-all, retry-bucket, …) now shows a **Retry** button on the Jobs history table, and the page header gains a **Retry all failed** button whenever at least one such job is listed. Retry re-runs the job from its stored spec exactly like a bookmark re-run (so bucket jobs re-derive from the channel's *current* state), and the re-run **jumps ahead of other queued work** (it's promoted to the front of its queue, reusing the new reorder machinery) so a fix-and-retry runs next rather than at the back of the line. The spec is resolved from the live registry or, for an evicted/archived job, from its on-disk `<id>.meta.json` sidecar — so even a failure the 100-job cap has dropped is still retryable. Kinds with no replay descriptor (e.g. `import-one`) intentionally offer no Retry. See `editor/app/jobs/actions.ts` (`retryJobAction` / `retryAllFailedAction`), the new `RetryJobButton` / `RetryAllFailedButton`, and `editor/e2e/jobs-retry.spec.ts`. - **Reorder and promote queued jobs from Active Jobs.** A queued job's row now carries **Promote / ↑ / ↓** controls (mirroring the auto-queue policy editor's move buttons) to change its order within its queue — Promote sends it to the front so it runs next, ↑/↓ nudge it one slot. Only actionable moves render (the first-queued job shows no up/promote, the last no down), and a running job is never displaced. See `editor/app/jobs/components/ReorderJobButtons.tsx`, the `reorderJobAction` / `promoteJobAction` server actions, and `editor/e2e/jobs-reorder.spec.ts`. diff --git a/editor/app/auto-queue/components/PolicyTreeEditor.tsx b/editor/app/auto-queue/components/PolicyTreeEditor.tsx @@ -481,7 +481,7 @@ function LeafControls(props: EditorProps & { leaf: AutoQueueLeaf }) { } className={inputClass} > - <option value="">default</option> + <option value="">all buckets (default)</option> {buckets.map((b) => ( <option key={b} value={b}> {b} diff --git a/editor/app/auto-queue/page.tsx b/editor/app/auto-queue/page.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { listChannels } from "yt-dlp-transcript-common/controller/channels"; import { PLATFORM_VALUES } from "yt-dlp-transcript-common/lib/platform"; +import { bucketsForKind } from "yt-dlp-transcript-common/jobs/autoQueuePolicy"; import { buildAutoQueueStatusPayload } from "./status"; import { AutoQueueView } from "./components/AutoQueueView"; @@ -10,11 +11,11 @@ export const dynamic = "force-dynamic"; export const metadata: Metadata = { title: "Auto-queue" }; -// Buckets each runner can draw from. Transcription: fresh downloaded-no- -// transcript plus retry-the-failed. Download: the channel's undownloaded ids. +// Buckets each runner can draw from — derived from the policy engine's single +// source of truth (bucketsForKind) so the picker can't drift from the runner. const BUCKETS_BY_KIND = { - transcription: ["downloadedNoTranscript", "failedListed"], - download: ["undownloadedIds"], + transcription: [...bucketsForKind("transcription")], + download: [...bucketsForKind("download")], }; export default async function AutoQueuePage() { diff --git a/homepage/CHANGELOG.md b/homepage/CHANGELOG.md @@ -36,6 +36,10 @@ chrome: the masthead is just the wordmark (no eyebrow/subtitle) and the chart and site-grid zones carry no section labels — separation is by space alone. Chart entry animation is disabled so bars/areas paint immediately. +- **Wordmark masthead = the header.** The big "Archilyzer" wordmark and the small + sticky header logo are one element (`app/components/SiteHeader.tsx`): large as a + masthead at the top of the home page, it shrinks to the compact sticky bar the + moment you scroll. Other routes always show the compact bar. - **Hub Markdown pages dropped.** The `[slug]` doc routes, the `index`-page home body, the `PageBody` renderer, and the doc nav are removed — the hub is now a single page (re-addable later if needed). See `app/page.tsx`, the new hub diff --git a/homepage/app/components/Header.tsx b/homepage/app/components/Header.tsx @@ -1,20 +1,10 @@ -import Link from "next/link"; import { currentHomepage } from "../lib/homepage"; +import { SiteHeader } from "./SiteHeader"; // Top nav for the hub. The hub is a single cross-site landing, so the header is -// just the branding mark linking home plus a quiet mono descriptor. +// just the branding mark + wordmark — large as a masthead on the home page, +// collapsing to a compact sticky bar on scroll (see SiteHeader). export default function Header() { const config = currentHomepage(); - return ( - <header className="sticky top-0 z-20 border-b border-[var(--border)] bg-[rgba(12,10,8,0.72)] backdrop-blur-md"> - <div className="max-w-6xl mx-auto px-5 sm:px-6 h-14 flex items-center gap-3"> - <Link href="/" className="group flex items-center gap-2.5"> - <span className="h-2.5 w-2.5 rotate-45 rounded-[2px] bg-[var(--accent)] shadow-[0_0_14px_var(--accent)] transition group-hover:bg-[var(--accent-strong)]" /> - <span className="font-display text-[1.35rem] leading-none font-semibold tracking-tight text-[var(--foreground)]"> - {config.headerTitle} - </span> - </Link> - </div> - </header> - ); + return <SiteHeader title={config.headerTitle} />; } diff --git a/homepage/app/components/SiteHeader.tsx b/homepage/app/components/SiteHeader.tsx @@ -0,0 +1,66 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useEffect, useState } from "react"; + +// The hub's single wordmark. On the home page it starts as a large masthead and +// collapses to the compact header bar the moment you scroll; every other route +// always shows the compact state. (Merges what used to be a separate hero +// heading + a small sticky logo into one element.) +export function SiteHeader({ title }: { title: string }) { + const isHome = usePathname() === "/"; + const [scrolled, setScrolled] = useState(false); + + useEffect(() => { + const onScroll = () => setScrolled(window.scrollY > 4); + onScroll(); + window.addEventListener("scroll", onScroll, { passive: true }); + return () => window.removeEventListener("scroll", onScroll); + }, []); + + const large = isHome && !scrolled; + + return ( + <header + className={ + "sticky top-0 z-20 transition-[background-color,border-color] duration-300 ease-out " + + (large + ? "border-b border-transparent" + : "border-b border-[var(--border)] bg-[rgba(12,10,8,0.72)] backdrop-blur-md") + } + > + <div + className={ + "max-w-6xl mx-auto px-5 sm:px-6 flex items-center transition-[height] duration-300 ease-out " + + (large ? "h-28 sm:h-40" : "h-14") + } + > + <Link href="/" className="group flex items-end gap-2.5 sm:gap-3.5"> + <span + className={ + "shrink-0 rotate-45 rounded-[3px] bg-[var(--accent)] transition-all duration-300 ease-out group-hover:bg-[var(--accent-strong)] " + + (large + ? "mb-1.5 h-3.5 w-3.5 sm:h-4 sm:w-4 shadow-[0_0_18px_var(--accent)]" + : "mb-1 h-2.5 w-2.5 shadow-[0_0_14px_var(--accent)]") + } + /> + {isHome ? ( + <h1 + className={ + "font-display font-semibold leading-[0.9] tracking-[-0.02em] text-[var(--foreground)] transition-[font-size] duration-300 ease-out " + + (large ? "text-4xl sm:text-6xl" : "text-[1.35rem]") + } + > + {title} + </h1> + ) : ( + <span className="font-display text-[1.35rem] font-semibold leading-none tracking-tight text-[var(--foreground)]"> + {title} + </span> + )} + </Link> + </div> + </header> + ); +} diff --git a/homepage/app/page.tsx b/homepage/app/page.tsx @@ -4,7 +4,6 @@ import { HOMEPAGE_SUMMARY_VERSION, type HomepageSummary, } from "yt-dlp-transcript-common/lib/homepageSummary"; -import { currentHomepage } from "./lib/homepage"; import { HomeLanding } from "./components/HomeLanding"; // The hub home route IS the cross-site landing: the wordmark, headline KPIs, one @@ -47,17 +46,8 @@ function loadSummary(): HomepageSummary { } export default function Home() { - const config = currentHomepage(); const summary = loadSummary(); - return ( - <div className="flex flex-col gap-12 sm:gap-16"> - <section className="reveal flex items-center gap-3.5 pt-2"> - <span className="h-3.5 w-3.5 rotate-45 rounded-[3px] bg-[var(--accent)] shadow-[0_0_18px_var(--accent)]" /> - <h1 className="font-display text-5xl sm:text-7xl font-semibold leading-[0.95] tracking-[-0.02em] text-[var(--foreground)]"> - {config.siteTitle} - </h1> - </section> - <HomeLanding summary={summary} /> - </div> - ); + // The wordmark masthead lives in the header now (SiteHeader), large at the top + // and shrinking on scroll — so the page starts straight into the landing. + return <HomeLanding summary={summary} />; }