Archilyzer · Source

archilyzer

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

commit a071ee9ff7e410f37604dcb7abf91cbabb78f2d1
parent beea654738fa96483cd7b8ef4c96127e5887fd1b
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun, 21 Jun 2026 22:48:34 -0400

Add download/transcription progress charts (content added over time)

Track when content was *added to the sites* (acquisition date), distinct
from when the creator uploaded it, and chart it through the existing
charts UI — channel-filterable, cumulative, CSV/PNG, shareable URLs.

- VideoStat gains nullable downloadedDate/transcribedDate (YYYYMMDD, UTC);
  STATS_SCHEMA_VERSION 3 -> 4 forces a one-time full re-extraction.
- buildStats resolves acquisition dates from the per-video download-outcome
  (finishedAt) and a new transcribe-outcome.json sidecar, falling back to
  file mtimes for content added before the sidecars existed.
- transcribeOne writes transcribe-outcome.json when a transcript is
  finalized (best-effort; a failed write never fails the transcription).
- The time X-axis gains a `field` selector (uploadDate default for URL
  back-compat) wired through chartAggregate, chartShare, and both chart UIs
  (ChartConfigEditor + ChartShapeControls).
- New "Content added" preset group; the cumulative library-growth-by-
  acquisition chart is seeded into the default dashboard, so it appears in
  both the editor and export with no per-app wiring.

Verified: tsc clean (common/editor/export), lint clean, 8/8 charts e2e
(incl. a new Date-field test), and a real rebuild of 38,248 videos
populated the fields correctly.

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

Diffstat:
Mcommon/components/charts/ChartConfigEditor.tsx | 57+++++++++++++++++++++++++++++++++++++++++----------------
Mcommon/components/charts/ChartShapeControls.tsx | 57+++++++++++++++++++++++++++++++++++++++++----------------
Mcommon/controller/buildStats.ts | 0
Mcommon/controller/transcribeOne.ts | 18++++++++++++++++++
Mcommon/lib/chartAggregate.ts | 6+++++-
Mcommon/lib/chartConfig.ts | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcommon/lib/chartShare.ts | 15+++++++++++++--
Mcommon/lib/stats.ts | 10++++++++--
Acommon/lib/transcribeOutcome-server.ts | 38++++++++++++++++++++++++++++++++++++++
Acommon/lib/transcribeOutcome.ts | 21+++++++++++++++++++++
Mcommon/lib/transcripts-server.ts | 7+++++++
Meditor/CHANGELOG.md | 1+
Mexport/CHANGELOG.md | 3+++
Mexport/e2e/charts.spec.ts | 27+++++++++++++++++++++++++++
Mexport/e2e/fixtures/data.ts | 7+++++++
15 files changed, 292 insertions(+), 39 deletions(-)

diff --git a/common/components/charts/ChartConfigEditor.tsx b/common/components/charts/ChartConfigEditor.tsx @@ -8,6 +8,7 @@ import type { MetricField, SeriesGroupBy, TimeBin, + TimeField, XAxisField, YMetric, } from "../../lib/chartConfig"; @@ -161,27 +162,51 @@ export function ChartConfigEditor({ else patchX({ kind: "numericBucket", field: "duration", bucketSize: 300 }); }} > - <option value="time">Upload date</option> + <option value="time">Date (time series)</option> <option value="category">Category</option> <option value="numericBucket">Duration buckets</option> </select> </label> {draft.x.kind === "time" && ( - <label className={lbl}> - Bin - <select - className={sel} - value={draft.x.bin} - onChange={(e) => - patchX({ kind: "time", bin: e.target.value as TimeBin }) - } - > - <option value="week">Week</option> - <option value="month">Month</option> - <option value="quarter">Quarter</option> - <option value="year">Year</option> - </select> - </label> + <> + <label className={lbl}> + Date field + <select + className={sel} + value={draft.x.field ?? "uploadDate"} + onChange={(e) => + patchX({ + kind: "time", + bin: (draft.x as { bin: TimeBin }).bin, + field: e.target.value as TimeField, + }) + } + > + <option value="uploadDate">Uploaded (by creator)</option> + <option value="downloadedDate">Downloaded (added)</option> + <option value="transcribedDate">Transcribed</option> + </select> + </label> + <label className={lbl}> + Bin + <select + className={sel} + value={draft.x.bin} + onChange={(e) => + patchX({ + kind: "time", + bin: e.target.value as TimeBin, + field: (draft.x as { field?: TimeField }).field, + }) + } + > + <option value="week">Week</option> + <option value="month">Month</option> + <option value="quarter">Quarter</option> + <option value="year">Year</option> + </select> + </label> + </> )} {draft.x.kind === "category" && ( <label className={lbl}> diff --git a/common/components/charts/ChartShapeControls.tsx b/common/components/charts/ChartShapeControls.tsx @@ -9,6 +9,7 @@ import { type MetricField, type SeriesGroupBy, type TimeBin, + type TimeField, type XAxisField, type YMetric, } from "../../lib/chartConfig"; @@ -181,27 +182,51 @@ export function ChartShapeControls({ }); }} > - <option value="time">Upload date</option> + <option value="time">Date (time series)</option> <option value="category">Category</option> <option value="numericBucket">Duration buckets</option> </select> </label> {shape.x.kind === "time" && ( - <label className={lbl}> - Bin - <select - className={sel} - value={shape.x.bin} - onChange={(e) => - patchX({ kind: "time", bin: e.target.value as TimeBin }) - } - > - <option value="week">Week</option> - <option value="month">Month</option> - <option value="quarter">Quarter</option> - <option value="year">Year</option> - </select> - </label> + <> + <label className={lbl}> + Date field + <select + className={sel} + value={shape.x.field ?? "uploadDate"} + onChange={(e) => + patchX({ + kind: "time", + bin: (shape.x as { bin: TimeBin }).bin, + field: e.target.value as TimeField, + }) + } + > + <option value="uploadDate">Uploaded (by creator)</option> + <option value="downloadedDate">Downloaded (added)</option> + <option value="transcribedDate">Transcribed</option> + </select> + </label> + <label className={lbl}> + Bin + <select + className={sel} + value={shape.x.bin} + onChange={(e) => + patchX({ + kind: "time", + bin: e.target.value as TimeBin, + field: (shape.x as { field?: TimeField }).field, + }) + } + > + <option value="week">Week</option> + <option value="month">Month</option> + <option value="quarter">Quarter</option> + <option value="year">Year</option> + </select> + </label> + </> )} {shape.x.kind === "category" && ( <label className={lbl}> diff --git a/common/controller/buildStats.ts b/common/controller/buildStats.ts Binary files differ. diff --git a/common/controller/transcribeOne.ts b/common/controller/transcribeOne.ts @@ -11,6 +11,7 @@ import { normalizeTranscript } from "./normalizeTranscript"; import { pingRemoteHealth, transcribeViaRemote } from "./remoteTranscribe"; import { TranscribeError } from "./transcribeError"; import { isRealAudioFile } from "../lib/videoStatus"; +import { writeTranscribeOutcome } from "../lib/transcribeOutcome-server"; const { pathExists, readdir, rename, writeFile } = fs; @@ -187,6 +188,23 @@ export async function transcribeOneVideo( `Transcribe ${opts.videoId} done in ${((Date.now() - start) / 1000).toFixed(2)}s`, ); + // Persist when this transcript was acquired so the stats build can chart + // "content added over time" by transcription date. Best-effort: a failed + // sidecar write must not fail an otherwise-successful transcription (the build + // falls back to the transcript file mtime when the sidecar is absent). + try { + await writeTranscribeOutcome(opts.videoDir, { + videoId: opts.videoId, + transcribedAt: new Date().toISOString(), + worker: { id: worker.id, appId: worker.appId, kind: worker.kind }, + durationMs: Date.now() - start, + }); + } catch (err) { + log( + `Warning: failed to write transcribe-outcome.json for ${opts.videoId}: ${(err as Error).message}`, + ); + } + const channelSlug = deriveChannelSlug(opts.paths, opts.videoDir); if (channelSlug) { try { diff --git a/common/lib/chartAggregate.ts b/common/lib/chartAggregate.ts @@ -113,7 +113,11 @@ export function xLabelsForStat( ): { label: string; sort: number | string }[] { switch (x.kind) { case "time": { - const label = binTime(stat.uploadDate, x.bin); + // Bin on the chosen date field (defaults to uploadDate). Acquisition dates + // can be null (no resolvable source) — binTime returns null for those, so + // the stat simply contributes to no time bucket. + const ymd = stat[x.field ?? "uploadDate"]; + const label = ymd ? binTime(ymd, x.bin) : null; return label ? [{ label, sort: label }] : []; } case "numericBucket": { diff --git a/common/lib/chartConfig.ts b/common/lib/chartConfig.ts @@ -8,6 +8,11 @@ import { newGroup, newLeaf, stringifyRoot } from "./searchQuery"; export type TimeBin = "week" | "month" | "quarter" | "year"; +// Which date field the time X-axis bins on. "uploadDate" (default) charts when +// the creator published; the acquisition dates chart when *we* added the content +// (the "content added over time" progress charts). +export type TimeField = "uploadDate" | "downloadedDate" | "transcribedDate"; + export type CategoryField = | "channel" | "platform" @@ -21,7 +26,9 @@ export type CategoryField = export type NumericBucketField = "duration"; export type XAxisField = - | { kind: "time"; bin: TimeBin } + // `field` is optional and defaults to "uploadDate" so charts/URLs authored + // before acquisition dates existed still parse and behave identically. + | { kind: "time"; bin: TimeBin; field?: TimeField } | { kind: "category"; field: CategoryField } | { kind: "numericBucket"; field: NumericBucketField; bucketSize: number }; @@ -266,6 +273,55 @@ const OVERVIEW_TEMPLATES: GalleryTemplate[] = [ }, ]; +// Progress report: when content was *added to the sites*, as opposed to when the +// creator uploaded it (OVERVIEW_TEMPLATES). These bin the time axis on the +// acquisition date fields populated by the stats build from the download/ +// transcribe outcome sidecars. Grouped by channel so the per-channel breakdown +// the charts UI offers is on by default. +const PROGRESS_TEMPLATES: GalleryTemplate[] = [ + { + id: "preset-added-growth", + title: "Library growth (added)", + description: "Cumulative count of videos by the month we downloaded them.", + config: newChart({ + id: "preset-added-growth", + title: "Library growth (added)", + type: "area", + x: { kind: "time", bin: "month", field: "downloadedDate" }, + y: { agg: "count" }, + groupBy: "channel", + cumulative: true, + }), + }, + { + id: "preset-transcribed-over-time", + title: "Transcribed over time", + description: "Cumulative count of videos by the month we transcribed them.", + config: newChart({ + id: "preset-transcribed-over-time", + title: "Transcribed over time", + type: "area", + x: { kind: "time", bin: "month", field: "transcribedDate" }, + y: { agg: "count" }, + groupBy: "channel", + cumulative: true, + }), + }, + { + id: "preset-added-per-month", + title: "Added per month", + description: "Videos downloaded each month, stacked by channel.", + config: newChart({ + id: "preset-added-per-month", + title: "Added per month", + type: "stackedBar", + x: { kind: "time", bin: "month", field: "downloadedDate" }, + y: { agg: "count" }, + groupBy: "channel", + }), + }, +]; + // Search-derived starters. The query term is empty — adding one opens the // editor so the user types what to search for. const SEARCH_TEMPLATES: GalleryTemplate[] = [ @@ -314,6 +370,7 @@ const SEARCH_TEMPLATES: GalleryTemplate[] = [ export const PRESET_GROUPS: GalleryGroup[] = [ { id: "search", title: "Search examples", templates: SEARCH_TEMPLATES }, { id: "overview", title: "Overview", templates: OVERVIEW_TEMPLATES }, + { id: "progress", title: "Content added", templates: PROGRESS_TEMPLATES }, ]; // ─── "Chart this search" handoff ─── @@ -407,10 +464,13 @@ export function defaultDashboard(): Dashboard { version: 1, title: "Overview", filters: {}, - // Search charts first, then the metadata overview charts. + // Search charts first, then the metadata overview charts, then the headline + // "content added" progress chart so the acquisition view is present by + // default in both the editor dashboard and the export sites. charts: [ ...DEFAULT_SEARCH_CHARTS, ...OVERVIEW_TEMPLATES.slice(0, 4).map((t) => t.config), + PROGRESS_TEMPLATES[0].config, ], }; } diff --git a/common/lib/chartShare.ts b/common/lib/chartShare.ts @@ -13,6 +13,7 @@ import { type Dashboard, type SeriesGroupBy, type TimeBin, + type TimeField, type XAxisField, type YMetric, } from "./chartConfig"; @@ -79,8 +80,14 @@ function decFilters(raw: unknown): ChartFilters { } // ─── X axis ─── +const TIME_FIELDS: TimeField[] = ["uploadDate", "downloadedDate", "transcribedDate"]; function encX(x: XAxisField): SC { - if (x.kind === "time") return { k: "t", b: x.bin }; + if (x.kind === "time") { + const out: SC = { k: "t", b: x.bin }; + // Omit the default so legacy/short URLs stay unchanged. + if (x.field && x.field !== "uploadDate") out.f = x.field; + return out; + } if (x.kind === "numericBucket") return { k: "n", f: x.field, s: x.bucketSize }; return { k: "c", f: x.field }; } @@ -113,7 +120,11 @@ function decX(raw: unknown): XAxisField { ), }; } - return { kind: "time", bin: oneOf(r.b, BINS, "month") }; + return { + kind: "time", + bin: oneOf(r.b, BINS, "month"), + field: oneOf(r.f, TIME_FIELDS, "uploadDate"), + }; } // ─── Y metric ─── diff --git a/common/lib/stats.ts b/common/lib/stats.ts @@ -2,7 +2,7 @@ import type { Platform } from "./platform"; // Bumping this invalidates the LMDB `statsByPath` incremental cache and forces // a full re-extraction (e.g. when a new field is added below). -export const STATS_SCHEMA_VERSION = 3; +export const STATS_SCHEMA_VERSION = 4; export const STATS_MANIFEST_VERSION = 1; // Visibility of a video on its source platform, derived from the availability @@ -31,7 +31,13 @@ export type VideoStat = { channel: string; // display name title: string; platform: Platform; - uploadDate: string; // YYYYMMDD + uploadDate: string; // YYYYMMDD — when the creator published + // Acquisition dates (YYYYMMDD, UTC) — when *we* added the content. Null when no + // source is resolvable (pre-sidecar video with no usable mtime). Used by the + // "content added over time" progress charts; the time X-axis can bin on any of + // these date fields. + downloadedDate: string | null; + transcribedDate: string | null; timestamp: number | null; // unix seconds duration: number; // seconds viewCount: number | null; diff --git a/common/lib/transcribeOutcome-server.ts b/common/lib/transcribeOutcome-server.ts @@ -0,0 +1,38 @@ +import path from "node:path"; +import { readFile, rename, writeFile } from "node:fs/promises"; +import { + TRANSCRIBE_OUTCOME_FILENAME, + type TranscribeOutcomeRecord, +} from "./transcribeOutcome"; + +export function transcribeOutcomePath(videoDir: string): string { + return path.join(videoDir, TRANSCRIBE_OUTCOME_FILENAME); +} + +export async function loadTranscribeOutcome( + videoDir: string, +): Promise<TranscribeOutcomeRecord | null> { + try { + const raw = await readFile(transcribeOutcomePath(videoDir), "utf8"); + const parsed = JSON.parse(raw) as Partial<TranscribeOutcomeRecord>; + if ( + typeof parsed?.videoId === "string" && + typeof parsed.transcribedAt === "string" + ) { + return parsed as TranscribeOutcomeRecord; + } + return null; + } catch { + return null; + } +} + +export async function writeTranscribeOutcome( + videoDir: string, + record: TranscribeOutcomeRecord, +): Promise<void> { + const file = transcribeOutcomePath(videoDir); + const tmp = `${file}.tmp-${process.pid}`; + await writeFile(tmp, JSON.stringify(record, null, 2) + "\n"); + await rename(tmp, file); +} diff --git a/common/lib/transcribeOutcome.ts b/common/lib/transcribeOutcome.ts @@ -0,0 +1,21 @@ +// Client-safe types and constants for the per-video transcription outcome +// sidecar. Mirrors downloadOutcome.ts: server-only I/O lives in +// transcribeOutcome-server.ts. Written when a video is freshly transcribed so +// the stats build can record *when* the transcript was acquired (the download +// sidecar already records when the audio was acquired). File mtimes are an +// unreliable acquisition signal because the shard model rsyncs identical copies +// between machines, so the timestamp is persisted explicitly here. + +export type TranscribeOutcomeRecord = { + videoId: string; + // ISO 8601, set the moment transcript.json is finalized. + transcribedAt: string; + // Which worker produced it (id + engine app), for later attribution. Optional + // so older/hand-written records still validate; appId is absent for remote + // workers (they delegate to another app instance). + worker?: { id: string; appId?: string; kind: "local" | "remote" }; + // Wall-clock transcription time in milliseconds. + durationMs?: number; +}; + +export const TRANSCRIBE_OUTCOME_FILENAME = "transcribe-outcome.json"; diff --git a/common/lib/transcripts-server.ts b/common/lib/transcripts-server.ts @@ -134,6 +134,11 @@ export function summarizeStats( hasTranscript: boolean; cueCount: number | null; status: VideoStatus; + // Acquisition dates (YYYYMMDD, UTC) resolved by the caller from the + // download/transcribe outcome sidecars (with mtime fallback). Null when no + // source is resolvable. + downloadedDate: string | null; + transcribedDate: string | null; }, ): VideoStat { const base = summarize(channelSlug, videoDir, meta, configName); @@ -145,6 +150,8 @@ export function summarizeStats( title: base.title, platform: base.platform, uploadDate: base.uploadDate, + downloadedDate: transcript.downloadedDate, + transcribedDate: transcript.transcribedDate, timestamp: numOrNull(meta.timestamp), duration: base.duration, viewCount: numOrNull(meta.view_count), diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **Charts can now track content *added to the sites* over time, not just when creators uploaded it.** The chart engine previously only binned the time axis on a video's **upload date**. Two acquisition dates are now recorded per video — when *we* downloaded it and when *we* transcribed it — and the chart editor's X-axis gains a **Date field** selector (Uploaded / Downloaded / Transcribed) alongside the bin. A new **Content added** preset group ships three ready charts (cumulative *Library growth (added)*, cumulative *Transcribed over time*, and *Added per month* stacked by channel), and the default dashboard now includes the cumulative library-growth-by-acquisition chart so the progress view is present out of the box. Everything reuses the existing charts UI, so per-channel filtering, cumulative curves, CSV/PNG export, and shareable URLs all work unchanged. Acquisition dates come from the per-video `download-outcome.json` (`finishedAt`) and a new `transcribe-outcome.json` sidecar written when a transcript is finalized; the stats build falls back to file mtimes for content added before the sidecars existed. Requires a one-time data rebuild (`build:index` + `build:stats`) on the bumped `STATS_SCHEMA_VERSION`. See `common/lib/{stats,chartConfig,chartAggregate,chartShare,transcribeOutcome}.ts`, `common/controller/{buildStats,transcribeOne}.ts`, and `common/components/charts/ChartConfigEditor.tsx`. - **The Schedule page is now a one-stop editor for per-channel sync cadence.** The `/scheduler` page used to be read-only — you could see each channel's interval, last sync, and next-due time, but to *change* a cadence you had to open that channel's editor (Source → Auto-sync), one channel at a time, and the headline global toggles lived only in Settings. Now each row's **Interval** cell is an inline editor: pick a preset (Default / Off / Every 10–30m / Hourly / 6h / 12h / Daily / Weekly) **or** choose **Custom (minutes)…** and type an exact minute count, then **Save** — writing just `syncIntervalMinutes` to that channel's `config.json` and leaving every other field untouched (it does *not* go through the full channel-form merge). The page also gained a **Global controls** block to toggle the master **enable**, the **default interval**, and the **internal heartbeat** right there (the advanced knobs — concurrency, quiet hours, backoff — still link out to Settings). The underlying due logic is unchanged: a channel auto-syncs on the next heartbeat once `now − lastSyncedAt ≥ its interval`. The live status columns keep polling every 5s, but each row's editor holds its own state seeded once from the stored value, so a refresh can't clobber an in-progress edit. The preset list is shared with the channel editor (`editor/app/scheduler/intervalPresets.ts`), and `GET /api/scheduler/status` now carries each channel's raw `configuredIntervalMinutes` so the editor can tell *inherit-default* from *explicit-off* from *explicit-minutes*. See `editor/app/scheduler/actions.ts` and `editor/app/scheduler/components/{ChannelIntervalEditor,SchedulerSettingsForm}.tsx`. - **Scheduled sync can now run without an external cron job.** The sync scheduler previously only fired when an OS cron entry POSTed to `/api/scheduler/tick` (via `pnpm sync:tick`) — fine on a server, but a chore to set up just to call a function the editor already hosts in-process. The editor can now drive its own heartbeat through a **Next.js instrumentation hook** (`editor/instrumentation.ts`): on server startup it arms a single in-process timer that calls `runSchedulerTick()` directly — no HTTP, no cron, no token. Turn it on with **Settings → Sync scheduler → Internal heartbeat (seconds)**: `0` = off (keep using an external cron heartbeat), any positive value is clamped to `[15, 3600]`s and is the cadence the editor ticks itself at; the `SYNC_HEARTBEAT_SECONDS` env var overrides the setting at runtime. The timer is a self-rescheduling, `unref`'d `setTimeout` loop (so it never holds the process open and never overlaps a tick), re-reading the cadence each fire so a change takes effect on the next tick — though turning it on *from 0* needs a restart, since the timer is armed once at boot. It's modeled on the existing snapshot-scheduler timer and reuses the already-overlap-guarded `runSchedulerTick()`, so internal and external heartbeats are interchangeable and may even coexist. The **Schedule** page header now reports how ticks are driven ("internal heartbeat every N" vs. "external heartbeat (cron)"), and `GET /api/scheduler/status` carries the effective `heartbeatSeconds`. Defaults to off, so dev/test and existing cron installs are unchanged. One caveat for multi-instance deployments: the timer runs once *per server instance*, so a cluster against one data dir should set `SYNC_HEARTBEAT_SECONDS=0` on all but one — see `SCHEDULED_SYNC.md`. - **The channel video-list filters are now combinable (intersection), with a new Partial filter.** The filter chips above a channel's video list used to be single-select — clicking one replaced the last — and there was no way to filter for partial downloads at all. Each chip is now an independent toggle, and selecting several narrows to videos matching **all** of them (an intersection). A new **Partial** chip surfaces videos with a leftover `.part` download. The headline use: **Transcribed + Partial** finds videos that are already transcribed but still carry an orphaned `audio.<ext>.part` (e.g. cornbreadman's, where the transcript is done but the partial lingers as cruft) — previously unreachable because a video's status is a single mutually-exclusive value where `partial_download` outranks `transcribed`. To make combinations meaningful the **Transcribed** and **Partial** chips now key off the row's independent `transcribed` / `partial` flags rather than that status enum, so "Transcribed" alone now also includes transcribed videos that happen to carry a `.part` or a failed-transcoding marker (they *are* transcribed). The active set is mirrored to the URL as a comma-joined `?filter=a,b` via `history.replaceState` (so reload/share preserves it without an RSC refetch per toggle, and without racing the global auto-refresh), and the **All** chip clears the selection. See `editor/app/channels/[slug]/lib/videoRows.ts` and `editor/app/channels/[slug]/components/VideoListPane.tsx`. diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## [Unreleased] +- **New "Content added" charts: track when videos joined the library, not just when they were uploaded.** The time axis of any chart can now bin on **when we downloaded** or **transcribed** a video, in addition to its upload date — pick the **Date field** in the chart options. The dashboard ships a cumulative **Library growth (added)** chart by default (grouped by channel), and the chart gallery gains a **Content added** group with cumulative download/transcription curves and a per-month stacked-by-channel breakdown. Like every chart, these filter by channel and export to CSV/PNG. (Populating acquisition dates for existing videos requires a one-time data rebuild — `build:index` + `build:stats` re-extract on the bumped stats schema; videos added before this release fall back to file timestamps.) + ## [0.4.0] - 2026-06-14 - **Search video descriptions and tags.** Each search layer's scope dropdown gains two new options alongside Transcripts / Live chat / Title-channel: **Description** (matches the video's description text) and **Tags** (matches its keyword tags). They combine with the others through the same AND/OR/NOT query builder and regex, and — like any search — can be charted (e.g. videos whose *description* mentions a term, over time). Because descriptions/tags live in the per-video transcript data, a description/tags-only search loads the same data a transcript search does. - **New chart breakdowns: Media type, Status, and Tag.** Charts can now split or bucket videos by **Media type** (regular video / livestream / **Short** — Shorts were previously indistinguishable), by **Status** (available / unlisted / deleted — e.g. chart how many videos got deleted each month), and by **Tag** (a bar per keyword tag). Media type and Status are also available as series groupings. (Populating these for existing videos requires a one-time data rebuild — `build:index` + `build:stats` re-extract on the bumped schema versions.) diff --git a/export/e2e/charts.spec.ts b/export/e2e/charts.spec.ts @@ -118,6 +118,33 @@ test.describe("charts (search view mode)", () => { await expect(page.locator(".recharts-surface")).toBeVisible(); }); + test("time charts can bin on the Downloaded acquisition date", async ({ + page, + }) => { + await page.goto("/"); + await expect(page.getByTestId("results-summary")).toContainText("All videos"); + await chartTab(page).click(); + await expect(page.locator(".recharts-surface")).toBeVisible({ + timeout: 20_000, + }); + const opts = page.getByTestId("chart-options"); + const xAxis = page.locator(".recharts-xAxis"); + // Default metadata chart bins on Upload date → the three fixtures span three + // months (2026-01..03). + await expect(xAxis).toContainText("2026-01"); + await expect(xAxis).toContainText("2026-03"); + + // Switch the time axis to the Downloaded acquisition date. All three + // fixtures were downloaded in 2026-05, so the chart collapses to one bucket. + await opts + .locator('label:has-text("Date field") select') + .selectOption("downloadedDate"); + await expect(xAxis).toContainText("2026-05"); + await expect(xAxis).not.toContainText("2026-01"); + // Still three videos, just re-binned by when we added them. + await expect(page.getByTestId("chart-caption")).toContainText("3 videos"); + }); + test("Examples menu sets the chart shape", async ({ page }) => { await page.goto("/"); await runSearch(page, "alpha"); diff --git a/export/e2e/fixtures/data.ts b/export/e2e/fixtures/data.ts @@ -93,6 +93,11 @@ function makeStat( uploadDate: string, viewCount: number, duration: number, + // Acquisition dates default to the same month (2026-05) for all fixtures, so a + // chart binned by Downloaded/Transcribed collapses to a single category even + // though the three videos were uploaded in three different months. + downloadedDate: string | null = "20260515", + transcribedDate: string | null = "20260516", ) { return { slug: slug(id), @@ -101,6 +106,8 @@ function makeStat( channel: CHANNEL, platform: "rumble" as const, uploadDate, + downloadedDate, + transcribedDate, timestamp: Date.parse(`${uploadDate.slice(0, 4)}-${uploadDate.slice(4, 6)}-${uploadDate.slice(6, 8)}`) / 1000, duration, viewCount,