commit edcf17cd61c13da3a0d5817b079c9afe81cbac31
parent 3c7e40e62da2a8e7732bd91d19da797fcb385092
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 21 Sep 2026 13:13:55 -0400
tags S3.3: the video page says which tags are on a video, and who said so
A Tags panel beside the operation panels: every effective tag, every
suppression, and for each one whether it is a RULE HIT, a PIN, or a pin over a
rule that also fires — with the pin's or suppression's provenance (operator,
agent:x, umtool:x) and its date. A rule hit shows none, because its provenance
is the rule, which lives on /tags.
toggleVideoTagAction calls applyTagAssignmentsAction. There is no second write
path from this panel, which is the whole design: an operator's pin and an
agent's pin are the same write with a different source recorded.
THE KEY IS THE INDEX'S ID, NOT THE DIRECTORY NAME. loadVideoTags resolves it
through summarize() from the metadata it reads anyway — an Odysee record is
keyed by webpage_url_basename and a Rumble directory is named for the URL slug
while the record carries the embed id. Pinning under the directory name would
write a key nothing ever looks up. When the two differ the panel says so.
Rule hits cost ONE key lookup in index.mdb (the upload date makes it a direct
get), never a scan; with no index the panel shows pins and suppressions and
says why the rules are silent.
Co-Authored-By: Claude Opus <noreply@anthropic.com>
Diffstat:
4 files changed, 370 insertions(+), 0 deletions(-)
diff --git a/editor/app/channels/[slug]/videos/[id]/components/TagsPanel.tsx b/editor/app/channels/[slug]/videos/[id]/components/TagsPanel.tsx
@@ -0,0 +1,217 @@
+"use client";
+
+// One video's curated tags, and where each one came from.
+//
+// A tag lands here three ways and the panel never conflates them: a RULE that
+// fires at index-build time, a PIN somebody made, or a rule hit a person
+// REJECTED. A pin and a suppression carry provenance — `operator`, `agent:x`,
+// `umtool:<project>` — and a rule hit carries none, because its provenance is
+// the rule, which lives on /tags and can be changed there.
+//
+// Every button calls toggleVideoTagAction, which calls the one server action
+// the bulk bar, /api/ops/tag-videos and umtool also call. There is no second
+// write path from this panel.
+
+import { useState, useTransition } from "react";
+import Link from "next/link";
+import { toggleVideoTagAction } from "../videoActions";
+import type { VideoTagsView } from "../lib/videoTags";
+
+export function TagsPanel({ view }: { view: VideoTagsView }) {
+ const [rows, setRows] = useState(view.rows);
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState<string | null>(null);
+ const [picked, setPicked] = useState("");
+
+ const apply = (tag: string, op: "add" | "remove" | "suppress" | "unsuppress") => {
+ setError(null);
+ startTransition(async () => {
+ const result = await toggleVideoTagAction(
+ view.channelSlug,
+ view.videoId,
+ tag,
+ op,
+ );
+ if (!result.ok) {
+ setError(result.error);
+ return;
+ }
+ setRows((rs) => {
+ const existing = rs.find((r) => r.id === tag);
+ const next = {
+ id: tag,
+ label: existing?.label ?? tag,
+ ruleHit: existing?.ruleHit ?? false,
+ pinned: op === "add",
+ suppressed: op === "suppress",
+ source: op === "remove" || op === "unsuppress" ? undefined : "operator",
+ setAt:
+ op === "remove" || op === "unsuppress"
+ ? undefined
+ : new Date().toISOString(),
+ effective:
+ op === "add" ||
+ ((existing?.ruleHit ?? false) && op !== "suppress"),
+ };
+ const kept = next.effective || next.suppressed;
+ const without = rs.filter((r) => r.id !== tag);
+ return kept
+ ? [...without, next].sort((a, b) => a.label.localeCompare(b.label))
+ : without;
+ });
+ setPicked("");
+ });
+ };
+
+ const unused = view.defs.filter((d) => !rows.some((r) => r.id === d.id));
+
+ return (
+ <section
+ aria-label="tags"
+ data-testid="video-tags-panel"
+ className="rounded-lg border border-border bg-card p-3 flex flex-col gap-2"
+ >
+ <div className="flex items-baseline justify-between gap-3">
+ <h3 className="text-sm font-semibold">Tags</h3>
+ <Link href="/tags" className="text-xs text-muted-foreground underline">
+ vocabulary
+ </Link>
+ </div>
+
+ {view.directoryId && (
+ <p className="text-[11px] text-muted-foreground">
+ Tagged as <code className="font-mono">{view.videoId}</code> — the id
+ the index and the export use; this directory is named{" "}
+ <code className="font-mono">{view.directoryId}</code>.
+ </p>
+ )}
+
+ {rows.length === 0 ? (
+ <p className="text-xs text-muted-foreground">
+ No tags on this video.
+ {!view.indexAvailable &&
+ " (No transcript index yet, so rules cannot be evaluated here.)"}
+ {view.indexAvailable &&
+ !view.indexed &&
+ " (Not in the index yet — rules will be evaluated at the next build.)"}
+ </p>
+ ) : (
+ <ul className="flex flex-col gap-1.5" aria-label="video tags">
+ {rows.map((r) => (
+ <li
+ key={r.id}
+ data-testid="video-tag"
+ data-tag={r.id}
+ data-state={
+ r.suppressed ? "suppressed" : r.pinned ? "pinned" : "rule"
+ }
+ className="flex flex-wrap items-center gap-2 text-xs"
+ >
+ <span
+ className={`rounded-full border px-2 py-0.5 ${
+ r.suppressed
+ ? "border-border text-muted-foreground line-through"
+ : "border-info/30 bg-info-soft text-info"
+ }`}
+ style={r.color && !r.suppressed ? { borderColor: r.color } : undefined}
+ >
+ {r.groupLabel ? `${r.groupLabel}: ${r.label}` : r.label}
+ </span>
+ <span className="text-muted-foreground">
+ {r.suppressed
+ ? "suppressed"
+ : r.pinned && r.ruleHit
+ ? "pinned (a rule also matches)"
+ : r.pinned
+ ? "pinned"
+ : "rule hit"}
+ {r.source ? ` · ${r.source}` : ""}
+ {r.setAt ? ` · ${r.setAt.slice(0, 10)}` : ""}
+ </span>
+ <span className="ml-auto flex items-center gap-1">
+ {r.suppressed ? (
+ <>
+ <button
+ type="button"
+ disabled={pending}
+ onClick={() => apply(r.id, "unsuppress")}
+ aria-label={`restore ${r.id}`}
+ className="rounded border border-border px-1.5 py-0.5 hover:bg-muted disabled:opacity-50"
+ >
+ Restore
+ </button>
+ <button
+ type="button"
+ disabled={pending}
+ onClick={() => apply(r.id, "add")}
+ aria-label={`pin ${r.id}`}
+ className="rounded border border-border px-1.5 py-0.5 hover:bg-muted disabled:opacity-50"
+ >
+ Pin
+ </button>
+ </>
+ ) : (
+ <>
+ {r.pinned && (
+ <button
+ type="button"
+ disabled={pending}
+ onClick={() => apply(r.id, "remove")}
+ aria-label={`unpin ${r.id}`}
+ className="rounded border border-border px-1.5 py-0.5 hover:bg-muted disabled:opacity-50"
+ >
+ Unpin
+ </button>
+ )}
+ <button
+ type="button"
+ disabled={pending}
+ onClick={() => apply(r.id, "suppress")}
+ aria-label={`suppress ${r.id}`}
+ className="rounded border border-border px-1.5 py-0.5 hover:bg-muted disabled:opacity-50"
+ >
+ Suppress
+ </button>
+ </>
+ )}
+ </span>
+ </li>
+ ))}
+ </ul>
+ )}
+
+ {unused.length > 0 && (
+ <div className="flex items-center gap-2 text-xs">
+ <select
+ value={picked}
+ onChange={(e) => setPicked(e.target.value)}
+ aria-label="add tag"
+ className="rounded border border-border bg-card px-1.5 py-1 text-xs text-foreground"
+ >
+ <option value="">Add a tag…</option>
+ {unused.map((d) => (
+ <option key={d.id} value={d.id}>
+ {d.label}
+ </option>
+ ))}
+ </select>
+ <button
+ type="button"
+ disabled={pending || !picked}
+ onClick={() => picked && apply(picked, "add")}
+ aria-label="pin selected tag"
+ className="rounded border border-border px-2 py-1 hover:bg-muted disabled:opacity-50"
+ >
+ Pin
+ </button>
+ </div>
+ )}
+
+ {error && (
+ <p role="status" className="text-xs text-warning">
+ {error}
+ </p>
+ )}
+ </section>
+ );
+}
diff --git a/editor/app/channels/[slug]/videos/[id]/lib/videoTags.ts b/editor/app/channels/[slug]/videos/[id]/lib/videoTags.ts
@@ -0,0 +1,119 @@
+import path from "node:path";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import {
+ loadRawMetadataFromDir,
+ summarize,
+} from "yt-dlp-transcript-common/lib/transcripts-server";
+import { ruleHitsForVideo } from "yt-dlp-transcript-common/controller/curatedTagsPreview";
+import {
+ assignmentKey,
+ effectiveTagsFor,
+ type CuratedTagDef,
+} from "yt-dlp-transcript-common/lib/curatedTags";
+import { readGlobalTags } from "../../../../../../lib/tagsStore";
+
+// What one video's Tags panel needs, read server-side.
+//
+// THE KEY IS THE INDEX'S ID, NOT THE DIRECTORY NAME. A video's data directory
+// is usually named for its id, but not always — summarize() takes
+// `webpage_url_basename` for Odysee, and a Rumble directory is named for the
+// URL slug while the record is keyed by the embed id. The index, the export
+// records and therefore every assignment use summarize()'s id, so resolving it
+// here (from the metadata the panel reads anyway) is what stops a pin made on
+// this page from landing on a key nothing ever looks up.
+
+export type VideoTagRow = {
+ id: string;
+ label: string;
+ color?: string;
+ group?: string;
+ groupLabel?: string;
+ // What a rule would say, and what a person said. Both are shown: "pinned over
+ // a rule that also fires" and "pinned, no rule" are different facts.
+ ruleHit: boolean;
+ pinned: boolean;
+ suppressed: boolean;
+ // Provenance of the pin or the suppression: operator | agent:x | umtool:x.
+ // Absent for a pure rule hit, whose provenance is the rule itself.
+ source?: string;
+ setAt?: string;
+ // True when this tag is on the video after folding (hits ∪ pins) − suppressed.
+ effective: boolean;
+};
+
+export type VideoTagsView = {
+ // The key assignments are written under — `<channelSlug>/<indexId>`.
+ channelSlug: string;
+ videoId: string;
+ // Set only when it differs from the directory name, so the panel can say so.
+ directoryId: string | null;
+ rows: VideoTagRow[];
+ // Every defined tag, for the "add tag" picker.
+ defs: { id: string; label: string }[];
+ // The index has never been built (or is unreadable): rules cannot be
+ // evaluated, so the panel shows pins and suppressions only and says why.
+ indexAvailable: boolean;
+ // The index is there but has no record for this video — undownloaded, or
+ // not yet rebuilt since it arrived.
+ indexed: boolean;
+};
+
+export async function loadVideoTags(
+ slug: string,
+ dirId: string,
+): Promise<VideoTagsView> {
+ const paths = getPaths();
+ const videoDir = path.join(paths.channelsDir, slug, "data", dirId);
+ const meta = await loadRawMetadataFromDir(videoDir);
+ const summary = meta ? summarize(slug, dirId, meta) : null;
+ const videoId = summary?.id ?? dirId;
+ const uploadDate = summary?.uploadDate;
+
+ const config = readGlobalTags(paths);
+ const defs: CuratedTagDef[] = config.tags;
+ const assignment = config.assignments[assignmentKey(slug, videoId)];
+ const { hits, indexAvailable, indexed } = ruleHitsForVideo(
+ paths,
+ defs,
+ slug,
+ videoId,
+ uploadDate,
+ );
+ const effective = new Set(effectiveTagsFor(hits, assignment, defs));
+
+ const manual = new Set(assignment?.manual ?? []);
+ const suppressed = new Set(assignment?.suppressed ?? []);
+ const shown = new Set<string>([...effective, ...suppressed]);
+ const defById = new Map(defs.map((d) => [d.id, d]));
+
+ const rows: VideoTagRow[] = [...shown]
+ .map((id) => {
+ const def = defById.get(id);
+ const provenance = assignment?.sources?.[id];
+ return {
+ id,
+ label: def?.label ?? id,
+ ...(def?.color ? { color: def.color } : {}),
+ ...(def?.group ? { group: def.group } : {}),
+ ...(def?.groupLabel ? { groupLabel: def.groupLabel } : {}),
+ ruleHit: hits.includes(id),
+ pinned: manual.has(id),
+ suppressed: suppressed.has(id),
+ ...(provenance
+ ? { source: provenance.source, setAt: provenance.setAt }
+ : {}),
+ effective: effective.has(id),
+ };
+ })
+ .sort((a, b) => a.label.localeCompare(b.label));
+
+ return {
+ channelSlug: slug,
+ videoId,
+ directoryId: videoId === dirId ? null : dirId,
+ rows,
+ defs: defs.map((d) => ({ id: d.id, label: d.label })),
+ indexAvailable,
+ indexed,
+ };
+}
diff --git a/editor/app/channels/[slug]/videos/[id]/page.tsx b/editor/app/channels/[slug]/videos/[id]/page.tsx
@@ -32,6 +32,8 @@ import { liveJobRows } from "../../../../jobs/active/buildActiveJobs";
import { RunningJobsList } from "../../../../jobs/components/RunningJobsList";
import { VideoPanel, type VideoFile } from "./components/VideoPanel";
import { OperationPanel } from "./components/OperationPanel";
+import { TagsPanel } from "./components/TagsPanel";
+import { loadVideoTags } from "./lib/videoTags";
import { loadVideoOperationPanels } from "./lib/videoOperationPanels";
export const dynamic = "force-dynamic";
@@ -158,6 +160,10 @@ export default async function VideoDetailPage({
settings: getSettings(),
});
+ // The curated tags on this video, with each one's provenance. Reads tags.json
+ // plus (for rule hits) ONE key out of the transcript index — not a scan.
+ const tagsView = await loadVideoTags(slug, id);
+
const registry = getRegistry();
const existingQueues = registry.activeQueueNames();
// Through the one builder, so this list has the same progress bars /jobs does
@@ -243,6 +249,8 @@ export default async function VideoDetailPage({
coverage={coverage}
/>
+ <TagsPanel view={tagsView} />
+
{/* The external operations' per-video surface is the pipeline stage
cards INSIDE VideoPanel above (download, transcode, transcription).
These are the registry's — one panel per entry, and a fifth entry
diff --git a/editor/app/channels/[slug]/videos/[id]/videoActions.ts b/editor/app/channels/[slug]/videos/[id]/videoActions.ts
@@ -55,6 +55,7 @@ import {
type FetchWindowProvenance,
} from "yt-dlp-transcript-common/ytdlp/fetchWindowManaged";
import { detectPlatform } from "yt-dlp-transcript-common/lib/platform";
+import { applyTagAssignmentsAction } from "../../../../tags/actions";
import {
platformCooldownRemainingMs,
recordDownloadBackoff,
@@ -667,6 +668,31 @@ export async function toggleExcludeTruncatedCheckAction(
return { ok: true };
}
+// One video's curated tags. THE ONE WRITER, reached: this calls
+// applyTagAssignmentsAction, which is what the /tags preview rows, the bulk bar,
+// /api/ops/tag-videos and umtool all call — so a pin made here and a pin made by
+// an agent are the same write, differing only in the provenance recorded.
+//
+// The id passed in is the one the INDEX uses (loadVideoTags resolves it from
+// metadata, because a Rumble directory is named for the URL slug while the
+// record is keyed by the embed id). Nothing here re-derives it.
+export async function toggleVideoTagAction(
+ slug: string,
+ videoId: string,
+ tag: string,
+ op: "add" | "remove" | "suppress" | "unsuppress",
+): Promise<{ ok: true; changed: number } | { ok: false; error: string }> {
+ const result = await applyTagAssignmentsAction({
+ op,
+ tag,
+ videos: [{ channelSlug: slug, id: videoId }],
+ });
+ if (!result.ok) return result;
+ revalidatePath(`/channels/${slug}/videos/${videoId}`);
+ revalidatePath(`/channels/${slug}/videos`);
+ return result;
+}
+
// Reverse persistence: move this video's stored source container back into its
// data dir and drop the pointer (Phase 5). The companion "persist" direction is
// redownloadToArchiveAction, which re-fetches the container when it's not on