commit ee832ca38a2623e816facc37de0f17b756b615e0
parent edcf17cd61c13da3a0d5817b079c9afe81cbac31
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 21 Sep 2026 13:17:35 -0400
tags S3.4: tag the selection, and filter the list by tag
Two additions to a channel's video list.
BULK. "Tag selected" / "Untag selected" join the action menu with a picker of
the defined tags. bulkApplyTagAction is ONE applyTagAssignments call for the
whole selection — the store reads, mutates and renames tags.json once per call,
so a per-id loop over four thousand selected videos would be four thousand
renames of a file every other surface is reading. `succeeded` is what actually
MOVED (re-pinning a pinned video is not a change and is not a failure).
CHIPS. A tag chip row above the list, an OR across chips, riding in the URL as
repeated `tg` params — the export viewer's spelling for the same question. The
status chips stay an intersection: "what state is this video in" composes,
"which subject is it about" does not.
WHAT THE CHIPS SHOW IS PINS, and the comment on VideoRow.curatedTags says why:
rule hits are derived at index build and never stored, so a list rendered off
disk cannot know them without re-running every rule over every video. Pins are
also the only thing this list can offer to change.
Both halves resolve DIRECTORY NAMES to RECORD IDS, because assignments are keyed
by the latter and they differ on Rumble, Odysee and Twitch (measured on the live
corpus: three channels here). The bulk path reads one metadata file per selected
video; the list path reads the index's `mtimes` map for the channel — and only
when an assigned id is not already a directory name, so an untagged or
YouTube-only channel pays nothing.
Co-Authored-By: Claude Opus <noreply@anthropic.com>
Diffstat:
7 files changed, 251 insertions(+), 6 deletions(-)
diff --git a/common/controller/curatedTagsPreview.ts b/common/controller/curatedTagsPreview.ts
Binary files differ.
diff --git a/editor/app/channels/[slug]/bulkVideoActions.ts b/editor/app/channels/[slug]/bulkVideoActions.ts
@@ -1,5 +1,7 @@
"use server";
+import path from "node:path";
+
import { revalidatePath } from "next/cache";
import type { StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
@@ -17,6 +19,11 @@ import {
redownloadShortAudioBucketAction,
} from "./incompleteTranscriptActions";
import {
+ loadRawMetadataFromDir,
+ summarize,
+} from "yt-dlp-transcript-common/lib/transcripts-server";
+import { applyTagAssignmentsAction } from "../../tags/actions";
+import {
deleteOneVideoDir,
markVideoUntranscribableAction,
removeAudioFilesForVideo,
@@ -217,3 +224,53 @@ export async function bulkClearFailedMarkersAction(
failures: [],
};
}
+
+// Tag (or untag) every selected video in ONE write.
+//
+// Not a loop over applyTagAssignmentsAction: the store reads tags.json,
+// mutates it and renames it once per call, so a per-id loop over four thousand
+// selected videos would be four thousand reads and four thousand renames of a
+// file every other surface is reading. The bulk bar's whole job is to be one
+// gesture, and this is one write.
+//
+// THE KEYS ARE RECORD IDS, NOT DIRECTORY NAMES. The list holds directory names;
+// assignments are keyed by the id the index and the export use, and the two
+// differ on Rumble, Odysee and Twitch. Resolving them costs one metadata read
+// per SELECTED video (bounded by the selection, not by the channel), which is
+// the same file the video page reads to draw its own panel.
+export async function bulkApplyTagAction(
+ slug: string,
+ videoIds: string[],
+ tag: string,
+ op: "add" | "remove",
+): Promise<BulkActionSummary> {
+ const paths = getPaths();
+ const channelDataDir = path.join(paths.channelsDir, slug, "data");
+ const videos: { channelSlug: string; id: string }[] = [];
+ for (const dirId of videoIds) {
+ const meta = await loadRawMetadataFromDir(path.join(channelDataDir, dirId));
+ const id = meta ? summarize(slug, dirId, meta).id : dirId;
+ videos.push({ channelSlug: slug, id });
+ }
+ const result = await applyTagAssignmentsAction({ op, tag, videos });
+ if (!result.ok) {
+ return {
+ ok: false,
+ attempted: videoIds.length,
+ succeeded: 0,
+ failures: videoIds.map((videoId) => ({ videoId, error: result.error })),
+ };
+ }
+ revalidatePath(`/channels/${slug}/videos`);
+ // `changed` counts the videos whose assignment actually MOVED — re-pinning a
+ // pinned video is not a failure and is not a change. Reporting attempted as
+ // succeeded would claim work that did not happen; reporting `changed` as
+ // succeeded would look like a partial failure. So: attempted is the
+ // selection, succeeded is what moved, and nothing failed.
+ return {
+ ok: true,
+ attempted: videoIds.length,
+ succeeded: result.changed,
+ failures: [],
+ };
+}
diff --git a/editor/app/channels/[slug]/components/VideoListPane.tsx b/editor/app/channels/[slug]/components/VideoListPane.tsx
@@ -7,6 +7,7 @@ import { useVirtualizer } from "yt-dlp-transcript-common/components/virtualizer"
import { VirtualRow } from "yt-dlp-transcript-common/components/VirtualRow";
import type { StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand";
import type { VideoRow, VideoFilter } from "../lib/videoRows";
+import type { TagDef } from "../lib/videoTagRows";
import { filterRows, serializeFilters } from "../lib/videoRows";
import { QueueControl } from "../../../components/QueueControl";
import {
@@ -20,6 +21,7 @@ import {
bulkRemoveWrongFormatAudioAction,
bulkRetryDownloadAction,
bulkTranscribeAction,
+ bulkApplyTagAction,
type BulkActionSummary,
} from "../bulkVideoActions";
@@ -33,6 +35,8 @@ type BulkAction =
| "clear_failed"
| "remove_audio"
| "remove_wrong_format"
+ | "tag"
+ | "untag"
| "delete";
const BULK_ACTION_OPTIONS: { value: BulkAction; label: string }[] = [
@@ -45,15 +49,21 @@ const BULK_ACTION_OPTIONS: { value: BulkAction; label: string }[] = [
{ value: "clear_failed", label: "Clear failed markers" },
{ value: "remove_audio", label: "Remove audio files" },
{ value: "remove_wrong_format", label: "Remove wrong-format audio" },
+ { value: "tag", label: "Tag selected" },
+ { value: "untag", label: "Untag selected" },
{ value: "delete", label: "Delete directories" },
];
type Props = {
slug: string;
rows: VideoRow[];
+ // The corpus vocabulary, for the bulk tag picker and the chip row. Empty when
+ // nothing has been defined on /tags, and then neither appears.
+ tagDefs: TagDef[];
selectedVideoId: string | null;
initialFilters: VideoFilter[];
initialQuery: string;
+ initialTagFilter: string[];
defaultTranscribeQueue: string;
defaultDownloadQueue: string;
existingQueues: string[];
@@ -92,9 +102,11 @@ const INITIAL_LIST_WIDTH_PX = 384;
export function VideoListPane({
slug,
rows,
+ tagDefs,
selectedVideoId,
initialFilters,
initialQuery,
+ initialTagFilter,
defaultTranscribeQueue,
defaultDownloadQueue,
existingQueues,
@@ -116,13 +128,27 @@ export function VideoListPane({
const [retryAbortOnError, setRetryAbortOnError] = useState(false);
const [action, setAction] = useState<BulkAction>("transcribe");
const [deleteConfirm, setDeleteConfirm] = useState("");
+ const [bulkTag, setBulkTag] = useState(tagDefs[0]?.id ?? "");
+ // TAG CHIPS ARE AN OR, and deliberately not part of the intersection above:
+ // the status chips answer "what state is this video in" (a video is in all of
+ // them at once or it is not), while tags answer "which of these subjects is
+ // this video about" — and an intersection there would mean "on mic AND in
+ // chat", which is not a question anybody asks of a subject list.
+ const [tagFilter, setTagFilter] = useState<Set<string>>(
+ () => new Set(initialTagFilter),
+ );
const visibleRows = useMemo(() => {
- const filtered = filterRows(rows, filters);
+ let filtered = filterRows(rows, filters);
+ if (tagFilter.size > 0) {
+ filtered = filtered.filter((r) =>
+ (r.curatedTags ?? []).some((t) => tagFilter.has(t)),
+ );
+ }
const q = query.trim().toLowerCase();
if (!q) return filtered;
return filtered.filter((r) => r.id.toLowerCase().includes(q));
- }, [rows, filters, query]);
+ }, [rows, filters, query, tagFilter]);
// VIRTUALIZED. The largest channel has ~11,000 videos, and one <li> + <Link>
// each is ~11,000 DOM nodes for a pane that shows twenty of them. The scroll
@@ -197,6 +223,20 @@ export function VideoListPane({
syncUrl(next, query);
}
+ // Tags are an OR over chips (see the state declaration), and they ride in the
+ // URL as `tg` — the same param name the export viewer uses for the same
+ // question, so a link is readable on either side.
+ function toggleTagFilter(value: string) {
+ const next = new Set(tagFilter);
+ if (next.has(value)) next.delete(value);
+ else next.add(value);
+ setTagFilter(next);
+ const url = new URL(window.location.href);
+ url.searchParams.delete("tg");
+ for (const t of next) url.searchParams.append("tg", t);
+ window.history.replaceState(null, "", url.toString());
+ }
+
// The "All" chip clears every active filter.
function clearFilters() {
const next = new Set<VideoFilter>();
@@ -216,6 +256,7 @@ export function VideoListPane({
const serialized = serializeFilters(filters);
if (serialized) params.set("filter", serialized);
if (query.trim()) params.set("q", query);
+ for (const t of tagFilter) params.append("tg", t);
params.set("video", videoId);
return `/channels/${slug}/videos?${params.toString()}`;
}
@@ -335,7 +376,10 @@ export function VideoListPane({
}
const deleteArmed = deleteConfirm.trim().toLowerCase() === "delete";
- const applyDisabled = pending || (action === "delete" && !deleteArmed);
+ const applyDisabled =
+ pending ||
+ (action === "delete" && !deleteArmed) ||
+ ((action === "tag" || action === "untag") && !bulkTag);
function handleApply() {
switch (action) {
@@ -398,6 +442,13 @@ export function VideoListPane({
return;
doSummaryBulk(bulkRemoveWrongFormatAudioAction);
break;
+ case "tag":
+ case "untag": {
+ if (!bulkTag) return;
+ const op = action === "tag" ? "add" : "remove";
+ doSummaryBulk((s, ids) => bulkApplyTagAction(s, ids, bulkTag, op));
+ break;
+ }
case "delete":
if (!deleteArmed) return;
doSummaryBulk(bulkDeleteVideoDirsAction, () => setDeleteConfirm(""));
@@ -448,6 +499,33 @@ export function VideoListPane({
);
})}
</div>
+ {tagDefs.length > 0 && (
+ <div className="flex flex-wrap items-center gap-1.5" aria-label="tag filters">
+ {tagDefs.map((t) => {
+ const active = tagFilter.has(t.id);
+ const count = rows.filter((r) =>
+ (r.curatedTags ?? []).includes(t.id),
+ ).length;
+ if (count === 0 && !active) return null;
+ return (
+ <button
+ key={t.id}
+ type="button"
+ aria-pressed={active}
+ aria-label={`tag ${t.id}`}
+ onClick={() => toggleTagFilter(t.id)}
+ className={`text-xs rounded-full border px-2.5 py-1 ${
+ active
+ ? "border-info/30 bg-info-soft text-info"
+ : "border-border bg-card hover:bg-muted"
+ }`}
+ >
+ {t.label} {count}
+ </button>
+ );
+ })}
+ </div>
+ )}
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span className="flex-shrink-0">
{visibleRows.length} of {rows.length} videos
@@ -655,6 +733,24 @@ export function VideoListPane({
actionLabel="bulk re-download incomplete"
/>
)}
+ {(action === "tag" || action === "untag") && (
+ <label className="flex items-center gap-1.5 text-xs text-muted-foreground">
+ <span>Tag</span>
+ <select
+ value={bulkTag}
+ onChange={(e) => setBulkTag(e.target.value)}
+ aria-label="bulk tag"
+ className="rounded border border-border bg-card px-1.5 py-1 text-xs text-foreground"
+ >
+ {tagDefs.length === 0 && <option value="">none defined</option>}
+ {tagDefs.map((t) => (
+ <option key={t.id} value={t.id}>
+ {t.label}
+ </option>
+ ))}
+ </select>
+ </label>
+ )}
{action === "delete" && (
<label className="flex items-center gap-1.5 text-xs text-destructive">
<span>
diff --git a/editor/app/channels/[slug]/lib/videoRows.ts b/editor/app/channels/[slug]/lib/videoRows.ts
@@ -49,6 +49,13 @@ export type VideoRow = {
shortAudio: boolean;
running: boolean;
status: VideoRowStatus;
+ // Curated tags PINNED on this video (transcripts/tags.json), resolved from
+ // the record id the assignments are keyed by. Rule hits are deliberately NOT
+ // here: they are derived at index-build time and never stored, so a list
+ // rendered from disk cannot know them without re-running every rule over
+ // every video. The chips therefore filter on what a person or an agent
+ // asserted, which is also the only thing this list can offer to change.
+ curatedTags?: string[];
};
// "all" is not a real filter — it's the empty selection. The rest are the
diff --git a/editor/app/channels/[slug]/lib/videoTagRows.ts b/editor/app/channels/[slug]/lib/videoTagRows.ts
@@ -0,0 +1,53 @@
+import type { Paths } from "yt-dlp-transcript-common/lib/paths";
+import { recordIdsByVideoDir } from "yt-dlp-transcript-common/controller/curatedTagsPreview";
+import { readGlobalTags } from "../../../../lib/tagsStore";
+import type { VideoRow } from "./videoRows";
+
+export type TagDef = { id: string; label: string };
+
+// Fold this channel's curated-tag PINS onto its video rows.
+//
+// Two things make this cheap enough to sit in a page that already walks eleven
+// thousand directories:
+//
+// 1. NOTHING TAGGED, NOTHING DONE. Assignments are keyed `<slug>/<id>`, so a
+// channel with no assignments is one prefix test over a file that is
+// usually absent, and the rows come back untouched.
+// 2. THE DIRECTORY MAP IS READ ONLY WHEN IT IS NEEDED. Assignments are keyed
+// by the RECORD id, the rows by DIRECTORY name, and the two differ on
+// Rumble, Odysee and Twitch. When every assigned id is already a directory
+// name there is nothing to resolve; only when one is not do we read the
+// index's `mtimes` map for this channel (one ranged read, and empty when
+// there is no index).
+//
+// Rule hits are not folded in and cannot be: they are derived at index build
+// and never stored. The chips filter on what somebody asserted.
+export function attachCuratedTags(
+ paths: Paths,
+ slug: string,
+ rows: VideoRow[],
+): { rows: VideoRow[]; defs: TagDef[] } {
+ const config = readGlobalTags(paths);
+ const defs: TagDef[] = config.tags.map((t) => ({ id: t.id, label: t.label }));
+ const prefix = `${slug}/`;
+ const mine = new Map<string, string[]>();
+ for (const [key, assignment] of Object.entries(config.assignments)) {
+ if (!key.startsWith(prefix)) continue;
+ const pinned = assignment.manual ?? [];
+ if (pinned.length > 0) mine.set(key.slice(prefix.length), pinned);
+ }
+ if (mine.size === 0) return { rows, defs };
+
+ const dirNames = new Set(rows.map((r) => r.id));
+ const needsMapping = [...mine.keys()].some((id) => !dirNames.has(id));
+ const byDir = needsMapping ? recordIdsByVideoDir(paths, slug) : new Map();
+
+ return {
+ rows: rows.map((row) => {
+ const recordId = byDir.get(row.id) ?? row.id;
+ const tags = mine.get(recordId) ?? mine.get(row.id);
+ return tags && tags.length > 0 ? { ...row, curatedTags: tags } : row;
+ }),
+ defs,
+ };
+}
diff --git a/editor/app/channels/[slug]/videos/components/VideoWorkspace.tsx b/editor/app/channels/[slug]/videos/components/VideoWorkspace.tsx
@@ -3,6 +3,7 @@
import type { ReactNode } from "react";
import { VideoListPane } from "../../components/VideoListPane";
import type { VideoFilter, VideoRow } from "../../lib/videoRows";
+import type { TagDef } from "../../lib/videoTagRows";
// The two-pane video workspace: the list on the left, the selected video's
// detail on the right.
@@ -23,9 +24,11 @@ import type { VideoFilter, VideoRow } from "../../lib/videoRows";
export function VideoWorkspace({
slug,
rows,
+ tagDefs,
selectedVideoId,
initialFilters,
initialQuery,
+ initialTagFilter,
defaultTranscribeQueue,
defaultDownloadQueue,
existingQueues,
@@ -33,9 +36,11 @@ export function VideoWorkspace({
}: {
slug: string;
rows: VideoRow[];
+ tagDefs: TagDef[];
selectedVideoId: string | null;
initialFilters: VideoFilter[];
initialQuery: string;
+ initialTagFilter: string[];
defaultTranscribeQueue: string;
defaultDownloadQueue: string;
existingQueues: string[];
@@ -50,9 +55,11 @@ export function VideoWorkspace({
<VideoListPane
slug={slug}
rows={rows}
+ tagDefs={tagDefs}
selectedVideoId={selectedVideoId}
initialFilters={initialFilters}
initialQuery={initialQuery}
+ initialTagFilter={initialTagFilter}
defaultTranscribeQueue={defaultTranscribeQueue}
defaultDownloadQueue={defaultDownloadQueue}
existingQueues={existingQueues}
diff --git a/editor/app/channels/[slug]/videos/page.tsx b/editor/app/channels/[slug]/videos/page.tsx
@@ -29,6 +29,7 @@ import { readChannelConfigCached } from "../lib/channelConfigCache";
import { parseFilters, filterRows, serializeFilters } from "../lib/videoRows";
import { computeVideoRows, readDataDirVideoIds } from "../lib/videoRowsServer";
import { normalizeBuckets } from "yt-dlp-transcript-common/views/pipeline/stageStatus";
+import { attachCuratedTags } from "../lib/videoTagRows";
export const dynamic = "force-dynamic";
@@ -93,6 +94,13 @@ export default async function ChannelVideosPage({
const rawFilter = typeof sp.filter === "string" ? sp.filter : undefined;
const filters = parseFilters(rawFilter);
const queryRaw = typeof sp.q === "string" ? sp.q : "";
+ // Tag chips ride in the URL as repeated `tg` params — the export viewer's
+ // spelling for the same question, and an OR across them.
+ const tagFilter = Array.isArray(sp.tg)
+ ? sp.tg.filter((t): t is string => typeof t === "string")
+ : typeof sp.tg === "string"
+ ? [sp.tg]
+ : [];
const paths = getPaths();
const config = await readChannelConfigCached(slug);
@@ -140,10 +148,24 @@ export default async function ChannelVideosPage({
excludedIds: excludedDownloadIds,
});
+ // Curated-tag pins, folded onto the rows so the list can offer Tag chips and
+ // a bulk Tag action. Free for an untagged channel: attachCuratedTags reads
+ // tags.json (usually absent) and returns the rows untouched when nothing in
+ // it names this channel.
+ const { rows: taggedRows, defs: tagDefs } = attachCuratedTags(
+ paths,
+ slug,
+ rows,
+ );
+
// The server's view of the ordering, used only for the detail pane's prev/next
// and position readout. The list itself re-filters on the client, which is why
// chip toggles don't navigate.
- const filteredRows = filterRows(rows, filters);
+ const filteredRows = filterRows(taggedRows, filters).filter(
+ (r) =>
+ tagFilter.length === 0 ||
+ (r.curatedTags ?? []).some((t) => tagFilter.includes(t)),
+ );
const q = queryRaw.trim().toLowerCase();
const orderedRows = q
? filteredRows.filter((r) => r.id.toLowerCase().includes(q))
@@ -157,12 +179,13 @@ export default async function ChannelVideosPage({
const filterParam = serializeFilters(filters);
if (filterParam) params.set("filter", filterParam);
if (queryRaw) params.set("q", queryRaw);
+ for (const t of tagFilter) params.append("tg", t);
params.set("video", videoId);
return `/channels/${slug}/videos?${params.toString()}`;
};
let detail = (
- <EmptyVideoPanel total={rows.length} filtered={orderedRows.length} />
+ <EmptyVideoPanel total={taggedRows.length} filtered={orderedRows.length} />
);
if (selectedVideoId) {
const videoDir = path.join(channelDataDir, selectedVideoId);
@@ -231,10 +254,12 @@ export default async function ChannelVideosPage({
return (
<VideoWorkspace
slug={slug}
- rows={rows}
+ rows={taggedRows}
+ tagDefs={tagDefs}
selectedVideoId={selectedVideoId}
initialFilters={[...filters]}
initialQuery={queryRaw}
+ initialTagFilter={tagFilter}
defaultTranscribeQueue={TRANSCRIPTION_QUEUE}
defaultDownloadQueue={platformDefaultQueueKey}
existingQueues={existingQueues}