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:
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,