Archilyzer · Source

archilyzer

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

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:
Aeditor/app/channels/[slug]/videos/[id]/components/TagsPanel.tsx | 217+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/videos/[id]/lib/videoTags.ts | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/[slug]/videos/[id]/page.tsx | 8++++++++
Meditor/app/channels/[slug]/videos/[id]/videoActions.ts | 26++++++++++++++++++++++++++
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