commit dc748c1cf24f9776621e6a89d8bda615eae63c1c
parent e314a77e0343147a7ea68089ca4423c45f076b90
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 28 Aug 2026 03:06:27 -0400
review: the judgements a person makes about the archive get a page under Corpus
Duplicate clusters and media-integrity findings are the two things /actionable
held that no lane runs and no byte count describes. Both are flag-only, both
carry a reviewed state, and both end in a person deciding something. They get
one page under Corpus: /review.
The sections move verbatim — `duplicate-shorts`, `duplicate-shorts empty`,
`duplicate cluster <id>`, `media scan`, `media scan empty`,
`media finding <key>` are the same labels, so duplicate-shorts.spec only
changes where it navigates. Four components (`RunDuplicateDetectionButton`,
`DuplicateClusterReview`, `RunMediaScanButton`, `MediaScanFindingRow`) move to
`review/components/`, the loader to `review/lib/loadReview.ts`, and the four
actions to `review/actions.ts` — where their `revalidatePath` finally names the
page that renders them. The channel page's Diagnostics stage follows
`RunMediaScanButton`; the command palette follows
`runDuplicateDetectionAction`.
`lib/actionable/actions.ts` is down to the two GLOBAL incomplete-transcript
fixes, which really are census actions: they act on every channel the census
lists.
The nav's Corpus group is Dashboard, Channels, Review. This is the entry the IA
doc promised at :93-97 and forgot at :66-73.
New `review.spec.ts` covers the page itself and the two empty states — the
media scan has had no e2e at all until now, and "never run" is precisely the
state that section must not render as "all clear".
Nothing under transcripts/ was read or written for this commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diffstat:
19 files changed, 960 insertions(+), 885 deletions(-)
diff --git a/editor/app/actionable/components/DuplicateClusterReview.tsx b/editor/app/actionable/components/DuplicateClusterReview.tsx
@@ -1,122 +0,0 @@
-"use client";
-
-import { useEffect, useState } from "react";
-import {
- reviewDuplicateClusterAction,
- type DuplicateClusterDecision,
- type ReviewDuplicateClusterResult,
-} from "../../lib/actionable/actions";
-import type { DuplicateClusterOverride } from "yt-dlp-transcript-common/lib/duplicates";
-
-type Status =
- | { kind: "idle" }
- | { kind: "running"; decision: DuplicateClusterDecision }
- | { kind: "done"; result: ReviewDuplicateClusterResult }
- | { kind: "error"; message: string };
-
-// NOTE ON THE aria-labels BELOW: none of them may contain the substring
-// "duplicate cluster <id>". That is the CARD's own label, and Playwright's
-// getByLabel matches by SUBSTRING, so a button labelled "confirm duplicate
-// cluster <id>" makes the card's own locator resolve to three elements and
-// every existing assertion on it dies with a strict-mode violation. That is
-// exactly what happened here, and this repo has now hit the same collision
-// three times (deploy-page's "Build & deploy" vs "Build & deploy all sites",
-// the settings placeholder collision, and this).
-export function DuplicateClusterReview({
- clusterId,
- override,
-}: {
- clusterId: string;
- override: DuplicateClusterOverride | undefined;
-}) {
- const [status, setStatus] = useState<Status>({ kind: "idle" });
- // Disabled until mounted, deliberately. A server-rendered button has no
- // handler until React hydrates, so a click before then fires NOTHING — no
- // request, no error, nothing to debug. That is the exact failure that made
- // the digest pilot's job look "un-created", and /actionable renders a page
- // heavy enough to hit the same window. See StreamActionLog.
- const [mounted, setMounted] = useState(false);
- useEffect(() => setMounted(true), []);
-
- async function decide(decision: DuplicateClusterDecision) {
- setStatus({ kind: "running", decision });
- try {
- setStatus({
- kind: "done",
- result: await reviewDuplicateClusterAction(clusterId, decision),
- });
- } catch (e) {
- setStatus({ kind: "error", message: (e as Error).message });
- }
- }
-
- const busy = status.kind === "running";
- const disabled = busy || !mounted;
- const confirmed = override?.confirmed === true;
- const rejected = override?.notDuplicate === true;
-
- return (
- <div className="flex items-center gap-2 flex-wrap">
- <button
- type="button"
- onClick={() => decide("confirmed")}
- disabled={disabled || confirmed}
- aria-label={`confirm cluster ${clusterId}`}
- className="px-2 py-1 rounded-md border border-border text-xs font-medium hover:bg-muted disabled:opacity-50"
- >
- {busy && status.decision === "confirmed" ? "Confirming…" : "Confirm"}
- </button>
- <button
- type="button"
- onClick={() => decide("not-duplicate")}
- disabled={disabled || rejected}
- aria-label={`reject cluster ${clusterId}`}
- className="px-2 py-1 rounded-md border border-border text-xs font-medium hover:bg-muted disabled:opacity-50"
- >
- {busy && status.decision === "not-duplicate" ? "Rejecting…" : "Not a duplicate"}
- </button>
- {(confirmed || rejected) && (
- <button
- type="button"
- onClick={() => decide("clear")}
- disabled={disabled}
- aria-label={`clear cluster decision ${clusterId}`}
- className="px-2 py-1 rounded-md text-xs text-muted-foreground underline hover:text-foreground disabled:opacity-50"
- >
- {busy && status.decision === "clear" ? "Clearing…" : "Undo"}
- </button>
- )}
- {status.kind === "done" && status.result.ok && (
- <span
- aria-label={`cluster review result ${clusterId}`}
- className="text-xs text-muted-foreground"
- >
- {status.result.shared > 0 || status.result.misaligned > 0
- ? `${status.result.shared} digest(s) shared` +
- (status.result.misaligned > 0
- ? `, ${status.result.misaligned} misaligned`
- : "")
- : "Saved."}
- </span>
- )}
- {status.kind === "done" && !status.result.ok && (
- <span
- role="alert"
- aria-label={`cluster review error ${clusterId}`}
- className="text-xs text-destructive"
- >
- {status.result.error}
- </span>
- )}
- {status.kind === "error" && (
- <span
- role="alert"
- aria-label={`cluster review error ${clusterId}`}
- className="text-xs text-destructive"
- >
- {status.message}
- </span>
- )}
- </div>
- );
-}
diff --git a/editor/app/actionable/components/MediaScanFindingRow.tsx b/editor/app/actionable/components/MediaScanFindingRow.tsx
@@ -1,121 +0,0 @@
-"use client";
-
-import { useState, useTransition } from "react";
-import Link from "next/link";
-import { formatBytes } from "yt-dlp-transcript-common/lib/format";
-import {
- mediaScanKey,
- type MediaScanFinding,
-} from "yt-dlp-transcript-common/lib/mediaScan";
-import { reviewMediaFindingAction } from "../../lib/actionable/actions";
-
-const VERDICT_LABEL: Record<MediaScanFinding["verdict"], string> = {
- ok: "ok",
- unreadable: "unreadable",
- truncated: "truncated",
- stray: "stray file",
- unknown: "could not tell",
-};
-
-const VERDICT_HELP: Record<MediaScanFinding["verdict"], string> = {
- ok: "",
- unreadable:
- "ffprobe could not parse the container at all. This file cannot be transcribed or diarized from.",
- truncated:
- "Decodes, but is materially shorter than the metadata says — the download was cut off.",
- stray:
- "Readable media that is not one of the app's own outputs: a leftover scratch or temp file taking up disk.",
- unknown:
- "The scan could not reach a conclusion. Nothing is claimed about this file.",
-};
-
-export function MediaScanFindingRow({
- finding,
- reviewed,
-}: {
- finding: MediaScanFinding;
- reviewed: boolean;
-}) {
- const [pending, startTransition] = useTransition();
- const [error, setError] = useState<string | null>(null);
- const key = mediaScanKey(finding);
-
- function toggle() {
- setError(null);
- startTransition(async () => {
- const r = await reviewMediaFindingAction(key, !reviewed);
- if (!r.ok) setError(r.error);
- });
- }
-
- return (
- <li
- aria-label={`media finding ${key}`}
- data-verdict={finding.verdict}
- className="border border-border rounded-md p-3 flex flex-col gap-1"
- >
- <div className="flex items-center gap-2 flex-wrap text-xs">
- <span className="rounded px-2 py-0.5 border border-border font-medium">
- {VERDICT_LABEL[finding.verdict]}
- </span>
- <span className="text-muted-foreground">
- tier {finding.tier} · {formatBytes(finding.bytes)}
- </span>
- {reviewed && (
- <span className="text-muted-foreground">· marked reviewed</span>
- )}
- </div>
- <p className="text-sm">
- <Link
- href={`/channels/${encodeURIComponent(finding.channelSlug)}/videos/${encodeURIComponent(finding.videoId)}`}
- className="underline underline-offset-2"
- >
- {finding.slug}
- </Link>{" "}
- <code className="text-xs">{finding.file}</code>
- </p>
- <p className="text-xs text-muted-foreground">
- {VERDICT_HELP[finding.verdict]}
- </p>
- {(finding.containerSeconds !== null ||
- finding.metadataSeconds !== null) && (
- <p className="text-xs text-muted-foreground">
- container{" "}
- {finding.containerSeconds === null
- ? "unreadable"
- : `${Math.round(finding.containerSeconds)}s`}{" "}
- · metadata{" "}
- {finding.metadataSeconds === null
- ? "unknown"
- : `${Math.round(finding.metadataSeconds)}s`}
- </p>
- )}
- {finding.detail && (
- <p className="text-xs text-muted-foreground font-mono break-all">
- {finding.detail}
- </p>
- )}
- <div className="flex items-center gap-2">
- {/* Deletion is deliberately NOT here. The convention in this app is
- report first, delete second: the per-file delete already exists on
- the video page, behind its own confirm, and a scan that could delete
- what it flagged would be a very different and much more dangerous
- thing than a scan. */}
- <button
- type="button"
- onClick={toggle}
- disabled={pending}
- aria-label={`${reviewed ? "unreview" : "review"} ${key}`}
- className="px-2 py-1 rounded border border-border text-xs hover:bg-muted disabled:opacity-50"
- >
- {reviewed ? "Un-review" : "Mark reviewed"}
- </button>
- {error && (
- <span role="alert" className="text-xs text-destructive">
- {error}
- </span>
- )}
- </div>
- </li>
- );
-}
diff --git a/editor/app/actionable/components/RunDuplicateDetectionButton.tsx b/editor/app/actionable/components/RunDuplicateDetectionButton.tsx
@@ -1,85 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import {
- runDuplicateDetectionAction,
- type DuplicateScope,
- type RunDuplicateDetectionResult,
-} from "../../lib/actionable/actions";
-
-type Status =
- | { kind: "idle" }
- | { kind: "running" }
- | { kind: "done"; result: RunDuplicateDetectionResult }
- | { kind: "error"; message: string };
-
-export function RunDuplicateDetectionButton() {
- const [scope, setScope] = useState<DuplicateScope>("shorts");
- const [status, setStatus] = useState<Status>({ kind: "idle" });
-
- async function handleClick() {
- setStatus({ kind: "running" });
- try {
- const result = await runDuplicateDetectionAction(scope);
- setStatus({ kind: "done", result });
- } catch (e) {
- setStatus({ kind: "error", message: (e as Error).message });
- }
- }
-
- const running = status.kind === "running";
- return (
- <div className="flex items-center gap-2">
- <label className="sr-only" htmlFor="duplicate-scope">
- Detection scope
- </label>
- <select
- id="duplicate-scope"
- aria-label="duplicate detection scope"
- value={scope}
- onChange={(e) => setScope(e.target.value as DuplicateScope)}
- disabled={running}
- className="text-sm rounded-md border border-border bg-transparent px-2 py-2"
- >
- <option value="shorts">Shorts only</option>
- <option value="all">All durations</option>
- </select>
- <button
- type="button"
- onClick={handleClick}
- disabled={running}
- aria-label="detect duplicate shorts"
- className="px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50"
- >
- {running ? "Detecting…" : "Detect duplicates"}
- </button>
- {status.kind === "done" && status.result.ok && (
- <span
- aria-label="detect duplicate shorts result"
- className="text-xs text-muted-foreground"
- >
- {status.result.clusters} cluster(s) ·{" "}
- {status.result.videosInClusters} video(s)
- </span>
- )}
- {status.kind === "done" && !status.result.ok && (
- <span
- role="alert"
- aria-label="detect duplicate shorts error"
- className="text-xs text-destructive"
- >
- {status.result.error}
- </span>
- )}
- {status.kind === "error" && (
- <span
- role="alert"
- aria-label="detect duplicate shorts error"
- className="text-xs text-destructive"
- >
- {status.message}
- </span>
- )}
- </div>
- );
-}
diff --git a/editor/app/actionable/components/RunMediaScanButton.tsx b/editor/app/actionable/components/RunMediaScanButton.tsx
@@ -1,93 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import {
- runMediaScanAction,
- type RunMediaScanResult,
-} from "../../lib/actionable/actions";
-
-type Status =
- | { kind: "idle" }
- | { kind: "running" }
- | { kind: "done"; result: RunMediaScanResult }
- | { kind: "error"; message: string };
-
-// The depth choice is the whole reason this has a select rather than one button.
-// The container read costs milliseconds per file; the full decode is a real
-// transcode of every suspect file, so it has to be asked for rather than
-// happening because someone clicked the obvious thing.
-type Depth = "quick" | "deep";
-
-export function RunMediaScanButton({ slug }: { slug?: string }) {
- const [depth, setDepth] = useState<Depth>("quick");
- const [status, setStatus] = useState<Status>({ kind: "idle" });
-
- async function handleClick() {
- setStatus({ kind: "running" });
- try {
- const result = await runMediaScanAction({
- ...(slug ? { channels: [slug] } : {}),
- deepProbe: depth === "deep",
- });
- setStatus({ kind: "done", result });
- } catch (e) {
- setStatus({ kind: "error", message: (e as Error).message });
- }
- }
-
- const running = status.kind === "running";
- return (
- <div className="flex items-center gap-2 flex-wrap">
- <label className="sr-only" htmlFor={`media-scan-depth-${slug ?? "all"}`}>
- Scan depth
- </label>
- <select
- id={`media-scan-depth-${slug ?? "all"}`}
- aria-label="media scan depth"
- value={depth}
- onChange={(e) => setDepth(e.target.value as Depth)}
- disabled={running}
- className="text-sm rounded-md border border-border bg-transparent px-2 py-2"
- >
- <option value="quick">Quick (read containers)</option>
- <option value="deep">Deep (decode suspect files)</option>
- </select>
- <button
- type="button"
- onClick={handleClick}
- disabled={running}
- aria-label={slug ? "scan channel media" : "scan media for corruption"}
- className="px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50"
- >
- {running ? "Scanning…" : slug ? "Scan this channel" : "Scan media"}
- </button>
- {status.kind === "done" && status.result.ok && (
- <span
- aria-label="media scan result"
- className="text-xs text-muted-foreground"
- >
- {status.result.filesScanned.toLocaleString()} file(s) checked ·{" "}
- {status.result.findings.toLocaleString()} finding(s)
- </span>
- )}
- {status.kind === "done" && !status.result.ok && (
- <span
- role="alert"
- aria-label="media scan error"
- className="text-xs text-destructive"
- >
- {status.result.error}
- </span>
- )}
- {status.kind === "error" && (
- <span
- role="alert"
- aria-label="media scan error"
- className="text-xs text-destructive"
- >
- {status.message}
- </span>
- )}
- </div>
- );
-}
diff --git a/editor/app/actionable/page.tsx b/editor/app/actionable/page.tsx
@@ -1,5 +1,4 @@
import type { Metadata } from "next";
-import Link from "next/link";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
import { getSettings } from "yt-dlp-transcript-common/lib/settings";
import {
@@ -11,7 +10,6 @@ import {
actionableBackfillMissingInputCount,
loadActionableSummary,
} from "../lib/actionable/loadActionable";
-import { loadReviewSummary } from "../lib/review/loadReview";
import {
channelWorkSections,
type SectionConfig,
@@ -19,22 +17,6 @@ import {
import { ChannelWorkTable } from "../components/channelWork/ChannelWorkTable";
import { InlineActionButton } from "../components/actions/InlineActionButton";
import { RefreshAllReportsButton } from "../channels/components/RefreshAllReportsButton";
-import { RunDuplicateDetectionButton } from "./components/RunDuplicateDetectionButton";
-import { RunMediaScanButton } from "./components/RunMediaScanButton";
-import { MediaScanFindingRow } from "./components/MediaScanFindingRow";
-import {
- mediaScanKey,
- mediaScanTotals,
- unreviewedFindings,
- type MediaScanOverrides,
- type MediaScanReport,
-} from "yt-dlp-transcript-common/lib/mediaScan";
-import { DuplicateClusterReview } from "./components/DuplicateClusterReview";
-import type {
- DuplicateCluster,
- DuplicateOverrides,
- DuplicateReport,
-} from "yt-dlp-transcript-common/lib/duplicates";
export const dynamic = "force-dynamic";
@@ -42,10 +24,7 @@ export const metadata: Metadata = { title: "Actionable" };
export default async function ActionablePage() {
const paths = getPaths();
- const [summary, review] = await Promise.all([
- loadActionableSummary(paths),
- loadReviewSummary(paths),
- ]);
+ const summary = await loadActionableSummary(paths);
// What the derived-data lane is actually called, given which operations are
// enabled. "Backfill" is its queue key; on this install it stands for three
// operations, and no button anyone presses should be named after a queue.
@@ -143,214 +122,6 @@ export default async function ActionablePage() {
summary={summary}
/>
)}
- <DuplicatesSection
- report={review.duplicates}
- overrides={review.duplicateOverrides}
- />
- <MediaScanSection
- report={review.mediaScan}
- overrides={review.mediaScanOverrides}
- />
</div>
);
}
-
-// Corrupt media already on disk. Flag-only, exactly like the duplicates section
-// above it — the probes have existed since the audio-checked download landed;
-// what was missing was ever running them against files that are already here.
-function MediaScanSection({
- report,
- overrides,
-}: {
- report: MediaScanReport | null;
- overrides: MediaScanOverrides;
-}) {
- const findings = report?.findings ?? [];
- const outstanding = unreviewedFindings(report, overrides);
- const totals = report ? mediaScanTotals(report) : null;
- return (
- <section aria-label="media scan" className="flex flex-col gap-2">
- <div className="flex items-center justify-between flex-wrap gap-2">
- <h2 className="text-lg font-semibold">
- Media integrity{outstanding.length > 0 ? ` (${outstanding.length})` : ""}
- </h2>
- <RunMediaScanButton />
- </div>
- <p className="text-sm text-muted-foreground">
- Checks the audio and video files still on disk for corruption or
- truncation. Flag-only — nothing here deletes anything.
- {totals && totals.scannedAt ? (
- <>
- {" "}
- Last scan{" "}
- <time dateTime={totals.scannedAt}>
- {new Date(totals.scannedAt).toLocaleString()}
- </time>{" "}
- · {totals.filesScanned.toLocaleString()} file(s) across{" "}
- {totals.videosScanned.toLocaleString()} video dir(s)
- {totals.deepProbed > 0
- ? `, ${totals.deepProbed.toLocaleString()} fully decoded`
- : ""}
- .
- </>
- ) : (
- " Never run."
- )}
- </p>
- {findings.length === 0 ? (
- <p
- aria-label="media scan empty"
- className="text-sm text-muted-foreground border border-dashed border-border rounded p-4"
- >
- {/* "Never run" is NOT "all clear", and saying so would be the one way
- this section could actively mislead. */}
- {report
- ? "Every file the scan read was intact."
- : "Run a scan to check the media on disk."}
- </p>
- ) : (
- <ul className="flex flex-col gap-3">
- {findings.map((f) => (
- <MediaScanFindingRow
- key={mediaScanKey(f)}
- finding={f}
- reviewed={Boolean(overrides.reviewed[mediaScanKey(f)])}
- />
- ))}
- </ul>
- )}
- </section>
- );
-}
-
-function DuplicatesSection({
- report,
- overrides,
-}: {
- report: DuplicateReport | null;
- overrides: DuplicateOverrides;
-}) {
- const clusters = report?.clusters ?? [];
- return (
- <section aria-label="duplicate-shorts" className="flex flex-col gap-2">
- <div className="flex items-center justify-between flex-wrap gap-2">
- <h2 className="text-lg font-semibold">Duplicate shorts</h2>
- <RunDuplicateDetectionButton />
- </div>
- <p className="text-sm text-muted-foreground">
- Cross-platform, cross-channel duplicate detection (metadata pre-cluster →
- transcript comparison). Flag-only — review and act manually.
- {report ? (
- <>
- {" "}
- Last run{" "}
- <time dateTime={report.generatedAt}>
- {new Date(report.generatedAt).toLocaleString()}
- </time>{" "}
- ·{" "}
- {report.runConfig.thresholdSeconds === null
- ? "all durations"
- : `≤ ${report.runConfig.thresholdSeconds}s`}
- .
- </>
- ) : (
- " Never run."
- )}
- </p>
- {clusters.length === 0 ? (
- <p
- aria-label="duplicate-shorts empty"
- className="text-sm text-muted-foreground border border-dashed border-border rounded p-4"
- >
- {report ? "No duplicate clusters found." : "Run detection to scan."}
- </p>
- ) : (
- <ul className="flex flex-col gap-3">
- {clusters.map((cluster) => (
- <DuplicateClusterCard
- key={cluster.clusterId}
- cluster={cluster}
- override={overrides.clusters[cluster.clusterId]}
- />
- ))}
- </ul>
- )}
- </section>
- );
-}
-
-function DuplicateClusterCard({
- cluster,
- override,
-}: {
- cluster: DuplicateCluster;
- override: DuplicateOverrides["clusters"][string] | undefined;
-}) {
- const matchLabel: Record<DuplicateCluster["matchKind"], string> = {
- "transcript-exact": "exact transcript",
- "transcript-near": "near transcript",
- "title-duration": "title + runtime",
- };
- return (
- <li
- aria-label={`duplicate cluster ${cluster.clusterId}`}
- className="border border-border rounded-md p-3 flex flex-col gap-2"
- >
- <div className="flex items-center gap-2 flex-wrap text-xs">
- <Badge>{matchLabel[cluster.matchKind]}</Badge>
- {/* Nothing compared these videos' content — one side has no transcript.
- The cluster is a suspect for a human, stays out of the built site,
- and shares no derived work until someone confirms it. */}
- {cluster.needsReview && !override?.confirmed && !override?.notDuplicate && (
- <Badge>needs review</Badge>
- )}
- {override?.confirmed && <Badge>confirmed</Badge>}
- {override?.notDuplicate && <Badge>not a duplicate</Badge>}
- {cluster.score !== null && (
- <Badge>score {cluster.score.toFixed(2)}</Badge>
- )}
- {cluster.contained && <Badge>clip-of-longer</Badge>}
- {cluster.crossPlatform && <Badge>cross-platform</Badge>}
- {cluster.crossChannel && <Badge>cross-channel</Badge>}
- <span className="text-muted-foreground">
- {cluster.videoRefs.length} videos · ~{cluster.durationBucket}s
- </span>
- {/* The confirm path exists precisely for `needsReview` clusters:
- clusterMaySharePartial fails closed, so until a human says
- "confirmed" these share no digest and never reach a built site.
- Content-confirmed clusters need no confirmation — but they can still
- be rejected, which is the only way to un-assert a wrong one. */}
- <span className="ml-auto">
- <DuplicateClusterReview
- clusterId={cluster.clusterId}
- override={override}
- />
- </span>
- </div>
- <ul className="flex flex-col gap-1">
- {cluster.videoRefs.map((ref) => (
- <li key={ref.slug} className="text-sm flex items-baseline gap-2 flex-wrap">
- <Link
- href={`/channels/${ref.channelSlug}/videos/${ref.id}`}
- className="underline hover:text-foreground"
- >
- {ref.title || ref.slug}
- </Link>
- <span className="text-xs text-muted-foreground font-mono">
- {ref.platform} · {ref.channelSlug} · {ref.uploadDate}
- {ref.hasTranscript ? "" : " · no transcript"}
- </span>
- </li>
- ))}
- </ul>
- </li>
- );
-}
-
-function Badge({ children }: { children: React.ReactNode }) {
- return (
- <span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-muted-foreground">
- {children}
- </span>
- );
-}
diff --git a/editor/app/channels/[slug]/components/stages/DiagnosticsStage.tsx b/editor/app/channels/[slug]/components/stages/DiagnosticsStage.tsx
@@ -28,7 +28,7 @@ import {
} from "../../../../components/ShardControl";
import { RetryBucketControl } from "../RetryBucketControl";
import { VideoIdList } from "../VideoIdList";
-import { RunMediaScanButton } from "../../../../actionable/components/RunMediaScanButton";
+import { RunMediaScanButton } from "../../../../review/components/RunMediaScanButton";
function parseConcurrency(s: string): number | undefined {
const trimmed = s.trim();
diff --git a/editor/app/components/CommandPalette.tsx b/editor/app/components/CommandPalette.tsx
@@ -27,7 +27,7 @@ import {
refreshAllChannelSnapshotsAction,
syncAllChannelsAction,
} from "../channels/actions";
-import { runDuplicateDetectionAction } from "../lib/actionable/actions";
+import { runDuplicateDetectionAction } from "../review/actions";
import { retryAllFailedAction, drainAllAction } from "../jobs/actions";
import {
pauseAllWorkersAction,
diff --git a/editor/app/lib/actionable/actions.ts b/editor/app/lib/actionable/actions.ts
@@ -1,20 +1,12 @@
"use server";
+// What is left of the actionable census's actions: the two GLOBAL incomplete-
+// transcript fixes, which act on every channel the census lists. The duplicate
+// and media-scan actions moved to ../../review/actions with the page that runs
+// them; the report refresh moved to ../../channels/actions.
+
import { revalidatePath } from "next/cache";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
-import { runManagedFunction } from "yt-dlp-transcript-common/jobs/streamCommand";
-import { drainStream } from "yt-dlp-transcript-common/jobs/drainStream";
-import {
- detectDuplicateShorts,
- readDuplicateReport,
- updateDuplicateOverride,
-} from "yt-dlp-transcript-common/controller/duplicateShorts";
-import {
- scanCorruptMedia,
- updateMediaScanOverride,
-} from "yt-dlp-transcript-common/controller/scanCorruptMedia";
-import { mediaScanTotals } from "yt-dlp-transcript-common/lib/mediaScan";
-import { shareClusterFromCanonical } from "yt-dlp-transcript-common/controller/digestSharing";
import {
clearIncompleteTranscriptsAction,
enableAutoRunners,
@@ -22,108 +14,6 @@ import {
} from "../../channels/[slug]/incompleteTranscriptActions";
import { loadActionableSummary } from "./loadActionable";
-export type DuplicateScope = "shorts" | "all";
-
-export type RunDuplicateDetectionResult =
- | { ok: true; clusters: number; videosInClusters: number }
- | { ok: false; error: string };
-
-// Runs the global cross-platform duplicate-shorts pass. `scope: "all"` removes
-// the duration cutoff (a heavier one-off run that also surfaces clip-of-longer
-// containment). Reads the cues + statsByPath written by build:index/build:stats,
-// so a build must have run first for meaningful results.
-export async function runDuplicateDetectionAction(
- scope: DuplicateScope = "shorts",
-): Promise<RunDuplicateDetectionResult> {
- const paths = getPaths();
- let clusters = 0;
- let videosInClusters = 0;
- const result = await runManagedFunction({
- kind: "detect-duplicates",
- // Empty queueKey: a local read-only scan, no reason to wait behind
- // sync/download work (see refreshAllChannelSnapshotsAction).
- queueKey: "",
- paths,
- fn: async (onLog, signal) => {
- const report = await detectDuplicateShorts({
- paths,
- thresholdSeconds: scope === "all" ? null : undefined,
- onLog,
- signal,
- });
- clusters = report.totals.clusters;
- videosInClusters = report.totals.videosInClusters;
- },
- });
- if (!result.ok) return { ok: false, error: result.error };
- await drainStream(result.stream);
- revalidatePath("/actionable");
- return { ok: true, clusters, videosInClusters };
-}
-
-// What a human can say about a cluster the detector could not decide.
-// confirmed — "I looked; these really are the same video." Unblocks
-// sharing AND publication for a title+duration suspect.
-// not-duplicate — "They are not." Suppresses the cluster entirely.
-// clear — undo, back to awaiting review.
-export type DuplicateClusterDecision = "confirmed" | "not-duplicate" | "clear";
-
-export type ReviewDuplicateClusterResult =
- | { ok: true; shared: number; misaligned: number }
- | { ok: false; error: string };
-
-// Record a review decision for one cluster.
-//
-// `updateDuplicateOverride` has existed — with the `confirmed` flag, the
-// read-modify-write, the atomic rename and the "an empty patch clears the
-// decision" rule — since duplicate review was built, and until now **nothing in
-// the repo called it**. `clusterMaySharePartial` fails closed, so every
-// needsReview cluster shared nothing and there was no way for a human to change
-// that. This is that missing caller.
-//
-// Confirming also attempts the share immediately, via the equally-uncalled
-// `shareClusterFromCanonical`: the point of confirming is to let derived work
-// flow, and making the operator wait for the canonical member's next sweep to
-// find out whether it would have would make the button feel inert. It is a
-// no-op when the canonical has no digest yet, which today is almost always.
-export async function reviewDuplicateClusterAction(
- clusterId: string,
- decision: DuplicateClusterDecision,
-): Promise<ReviewDuplicateClusterResult> {
- const paths = getPaths();
- try {
- const overrides = await updateDuplicateOverride(
- paths,
- clusterId,
- decision === "confirmed"
- ? { confirmed: true, notDuplicate: false }
- : decision === "not-duplicate"
- ? { notDuplicate: true, confirmed: false }
- : { confirmed: false, notDuplicate: false },
- );
-
- let shared = 0;
- let misaligned = 0;
- if (decision === "confirmed") {
- const report = await readDuplicateReport(paths);
- const cluster = report?.clusters.find((c) => c.clusterId === clusterId);
- if (cluster) {
- for (const outcome of await shareClusterFromCanonical(paths, cluster, {
- overrides,
- })) {
- if (outcome.status === "shared") shared++;
- else if (outcome.status === "misaligned") misaligned++;
- }
- }
- }
-
- revalidatePath("/actionable");
- return { ok: true, shared, misaligned };
- } catch (e) {
- return { ok: false, error: (e as Error)?.message ?? String(e) };
- }
-}
-
export type GlobalIncompleteResult =
| { ok: true; channels: number; affected: number }
| { ok: false; error: string };
@@ -175,69 +65,3 @@ export async function redownloadAllIncompleteTranscriptsAction(): Promise<Global
revalidatePath("/operations/[id]", "page");
return { ok: true, channels: slugs.length, affected: queued };
}
-
-
-// ---------------------------------------------------------------------------
-// Corrupt-media scan
-// ---------------------------------------------------------------------------
-
-export type RunMediaScanResult =
- | { ok: true; findings: number; filesScanned: number }
- | { ok: false; error: string };
-
-// Scan media already on disk. Reports; deletes nothing — deletion stays the
-// separate, explicit per-file click that already exists on the video page.
-//
-// `deepProbe` turns on the tier-2 full decode for files whose duration looks
-// wrong. Off by default because that tier is a real transcode per file: it has
-// to be budgeted like the digest sweep, not like a stat() walk.
-export async function runMediaScanAction(
- opts: { channels?: string[]; deepProbe?: boolean } = {},
-): Promise<RunMediaScanResult> {
- const paths = getPaths();
- let findings = 0;
- let filesScanned = 0;
- const perChannel = (opts.channels?.length ?? 0) > 0;
- const result = await runManagedFunction({
- kind: perChannel ? "scan-media-channel" : "scan-media",
- // Empty queueKey: local read-only disk work, no reason to wait behind
- // sync/download work (see runDuplicateDetectionAction).
- queueKey: "",
- paths,
- fn: async (onLog, signal) => {
- const report = await scanCorruptMedia({
- paths,
- channels: opts.channels,
- deepProbe: opts.deepProbe,
- onLog,
- signal,
- });
- findings = report.findings.length;
- filesScanned = mediaScanTotals(report).filesScanned;
- },
- });
- if (!result.ok) return { ok: false, error: result.error };
- await drainStream(result.stream);
- revalidatePath("/actionable");
- return { ok: true, findings, filesScanned };
-}
-
-export type ReviewMediaFindingResult =
- | { ok: true }
- | { ok: false; error: string };
-
-// "Looked at it; it is fine." Recorded in a sibling overrides file, because the
-// report is regenerated wholesale by every scan and a decision written into it
-// would be destroyed by the next run.
-export async function reviewMediaFindingAction(
- key: string,
- reviewed: boolean,
-): Promise<ReviewMediaFindingResult> {
- try {
- await updateMediaScanOverride(getPaths(), key, { reviewed });
- revalidatePath("/actionable");
- return { ok: true };
- } catch (e) {
- return { ok: false, error: (e as Error).message };
- }
-}
diff --git a/editor/app/lib/nav.ts b/editor/app/lib/nav.ts
@@ -3,6 +3,7 @@ import {
Activity,
CalendarClock,
ChartColumnBig,
+ ClipboardCheck,
Cpu,
Globe,
Hammer,
@@ -65,6 +66,12 @@ export const NAV_GROUPS: NavGroup[] = [
links: [
{ href: "/", label: "Dashboard", icon: LayoutDashboard, keywords: "home overview monitor widget pipeline" },
{ href: "/channels", label: "Channels", icon: Tv },
+ {
+ href: "/review",
+ label: "Review",
+ icon: ClipboardCheck,
+ keywords: "duplicates duplicate shorts media integrity scan corrupt review queue",
+ },
],
},
{
diff --git a/editor/app/lib/review/loadReview.ts b/editor/app/lib/review/loadReview.ts
@@ -1,47 +0,0 @@
-import type { Paths } from "yt-dlp-transcript-common/lib/paths";
-import { readDuplicateOverrides } from "yt-dlp-transcript-common/controller/duplicateShorts";
-import type {
- DuplicateOverrides,
- DuplicateReport,
-} from "yt-dlp-transcript-common/lib/duplicates";
-import type {
- MediaScanOverrides,
- MediaScanReport,
-} from "yt-dlp-transcript-common/lib/mediaScan";
-import {
- readMediaScanOverrides,
- readMediaScanReport,
-} from "yt-dlp-transcript-common/controller/scanCorruptMedia";
-import { getDuplicateReport } from "../requestCache";
-
-// The half of the old actionable summary that is a HUMAN judgement rather than
-// a lane's work: findings someone decides about, with the decisions kept beside
-// them. Split out of loadActionable because nothing but the review page ever
-// read these four — and the dashboard and the widget poll were paying the
-// 6.7 MB duplicates parse on every render for a list they never showed.
-export type ReviewSummary = {
- duplicates: DuplicateReport | null;
- // The human decisions kept alongside the report — a cluster's canonical
- // choice, "not a duplicate", and the `confirmed` flag that is the only thing
- // letting a needsReview cluster share derived work. Loaded here because the
- // review UI cannot show what has already been decided without it, and a
- // review queue that forgets its own answers re-asks every question.
- duplicateOverrides: DuplicateOverrides;
- // The corrupt-media scan, and the "reviewed, this one's fine" decisions kept
- // beside it. Null when the scan has never been run — which is NOT the same as
- // "nothing is wrong", and the section says so rather than rendering an
- // all-clear it has no evidence for.
- mediaScan: MediaScanReport | null;
- mediaScanOverrides: MediaScanOverrides;
-};
-
-export async function loadReviewSummary(paths: Paths): Promise<ReviewSummary> {
- const [duplicates, duplicateOverrides, mediaScan, mediaScanOverrides] =
- await Promise.all([
- getDuplicateReport(paths),
- readDuplicateOverrides(paths),
- readMediaScanReport(paths),
- readMediaScanOverrides(paths),
- ]);
- return { duplicates, duplicateOverrides, mediaScan, mediaScanOverrides };
-}
diff --git a/editor/app/review/actions.ts b/editor/app/review/actions.ts
@@ -0,0 +1,189 @@
+"use server";
+
+// THE ACTIONS BEHIND /review: the two detectors a human runs, and the two
+// decisions a human records about what they found. Both pairs are flag-only —
+// nothing here deletes anything — which is exactly why they belong to a review
+// page rather than to a lane.
+
+import { revalidatePath } from "next/cache";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { runManagedFunction } from "yt-dlp-transcript-common/jobs/streamCommand";
+import { drainStream } from "yt-dlp-transcript-common/jobs/drainStream";
+import {
+ detectDuplicateShorts,
+ readDuplicateReport,
+ updateDuplicateOverride,
+} from "yt-dlp-transcript-common/controller/duplicateShorts";
+import {
+ scanCorruptMedia,
+ updateMediaScanOverride,
+} from "yt-dlp-transcript-common/controller/scanCorruptMedia";
+import { mediaScanTotals } from "yt-dlp-transcript-common/lib/mediaScan";
+import { shareClusterFromCanonical } from "yt-dlp-transcript-common/controller/digestSharing";
+
+export type DuplicateScope = "shorts" | "all";
+
+export type RunDuplicateDetectionResult =
+ | { ok: true; clusters: number; videosInClusters: number }
+ | { ok: false; error: string };
+
+// Runs the global cross-platform duplicate-shorts pass. `scope: "all"` removes
+// the duration cutoff (a heavier one-off run that also surfaces clip-of-longer
+// containment). Reads the cues + statsByPath written by build:index/build:stats,
+// so a build must have run first for meaningful results.
+export async function runDuplicateDetectionAction(
+ scope: DuplicateScope = "shorts",
+): Promise<RunDuplicateDetectionResult> {
+ const paths = getPaths();
+ let clusters = 0;
+ let videosInClusters = 0;
+ const result = await runManagedFunction({
+ kind: "detect-duplicates",
+ // Empty queueKey: a local read-only scan, no reason to wait behind
+ // sync/download work (see refreshAllChannelSnapshotsAction).
+ queueKey: "",
+ paths,
+ fn: async (onLog, signal) => {
+ const report = await detectDuplicateShorts({
+ paths,
+ thresholdSeconds: scope === "all" ? null : undefined,
+ onLog,
+ signal,
+ });
+ clusters = report.totals.clusters;
+ videosInClusters = report.totals.videosInClusters;
+ },
+ });
+ if (!result.ok) return { ok: false, error: result.error };
+ await drainStream(result.stream);
+ revalidatePath("/review");
+ return { ok: true, clusters, videosInClusters };
+}
+
+// What a human can say about a cluster the detector could not decide.
+// confirmed — "I looked; these really are the same video." Unblocks
+// sharing AND publication for a title+duration suspect.
+// not-duplicate — "They are not." Suppresses the cluster entirely.
+// clear — undo, back to awaiting review.
+export type DuplicateClusterDecision = "confirmed" | "not-duplicate" | "clear";
+
+export type ReviewDuplicateClusterResult =
+ | { ok: true; shared: number; misaligned: number }
+ | { ok: false; error: string };
+
+// Record a review decision for one cluster.
+//
+// `updateDuplicateOverride` has existed — with the `confirmed` flag, the
+// read-modify-write, the atomic rename and the "an empty patch clears the
+// decision" rule — since duplicate review was built, and until now **nothing in
+// the repo called it**. `clusterMaySharePartial` fails closed, so every
+// needsReview cluster shared nothing and there was no way for a human to change
+// that. This is that missing caller.
+//
+// Confirming also attempts the share immediately, via the equally-uncalled
+// `shareClusterFromCanonical`: the point of confirming is to let derived work
+// flow, and making the operator wait for the canonical member's next sweep to
+// find out whether it would have would make the button feel inert. It is a
+// no-op when the canonical has no digest yet, which today is almost always.
+export async function reviewDuplicateClusterAction(
+ clusterId: string,
+ decision: DuplicateClusterDecision,
+): Promise<ReviewDuplicateClusterResult> {
+ const paths = getPaths();
+ try {
+ const overrides = await updateDuplicateOverride(
+ paths,
+ clusterId,
+ decision === "confirmed"
+ ? { confirmed: true, notDuplicate: false }
+ : decision === "not-duplicate"
+ ? { notDuplicate: true, confirmed: false }
+ : { confirmed: false, notDuplicate: false },
+ );
+
+ let shared = 0;
+ let misaligned = 0;
+ if (decision === "confirmed") {
+ const report = await readDuplicateReport(paths);
+ const cluster = report?.clusters.find((c) => c.clusterId === clusterId);
+ if (cluster) {
+ for (const outcome of await shareClusterFromCanonical(paths, cluster, {
+ overrides,
+ })) {
+ if (outcome.status === "shared") shared++;
+ else if (outcome.status === "misaligned") misaligned++;
+ }
+ }
+ }
+
+ revalidatePath("/review");
+ return { ok: true, shared, misaligned };
+ } catch (e) {
+ return { ok: false, error: (e as Error)?.message ?? String(e) };
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Corrupt-media scan
+// ---------------------------------------------------------------------------
+
+export type RunMediaScanResult =
+ | { ok: true; findings: number; filesScanned: number }
+ | { ok: false; error: string };
+
+// Scan media already on disk. Reports; deletes nothing — deletion stays the
+// separate, explicit per-file click that already exists on the video page.
+//
+// `deepProbe` turns on the tier-2 full decode for files whose duration looks
+// wrong. Off by default because that tier is a real transcode per file: it has
+// to be budgeted like the digest sweep, not like a stat() walk.
+export async function runMediaScanAction(
+ opts: { channels?: string[]; deepProbe?: boolean } = {},
+): Promise<RunMediaScanResult> {
+ const paths = getPaths();
+ let findings = 0;
+ let filesScanned = 0;
+ const perChannel = (opts.channels?.length ?? 0) > 0;
+ const result = await runManagedFunction({
+ kind: perChannel ? "scan-media-channel" : "scan-media",
+ // Empty queueKey: local read-only disk work, no reason to wait behind
+ // sync/download work (see runDuplicateDetectionAction).
+ queueKey: "",
+ paths,
+ fn: async (onLog, signal) => {
+ const report = await scanCorruptMedia({
+ paths,
+ channels: opts.channels,
+ deepProbe: opts.deepProbe,
+ onLog,
+ signal,
+ });
+ findings = report.findings.length;
+ filesScanned = mediaScanTotals(report).filesScanned;
+ },
+ });
+ if (!result.ok) return { ok: false, error: result.error };
+ await drainStream(result.stream);
+ revalidatePath("/review");
+ return { ok: true, findings, filesScanned };
+}
+
+export type ReviewMediaFindingResult =
+ | { ok: true }
+ | { ok: false; error: string };
+
+// "Looked at it; it is fine." Recorded in a sibling overrides file, because the
+// report is regenerated wholesale by every scan and a decision written into it
+// would be destroyed by the next run.
+export async function reviewMediaFindingAction(
+ key: string,
+ reviewed: boolean,
+): Promise<ReviewMediaFindingResult> {
+ try {
+ await updateMediaScanOverride(getPaths(), key, { reviewed });
+ revalidatePath("/review");
+ return { ok: true };
+ } catch (e) {
+ return { ok: false, error: (e as Error).message };
+ }
+}
diff --git a/editor/app/review/components/DuplicateClusterReview.tsx b/editor/app/review/components/DuplicateClusterReview.tsx
@@ -0,0 +1,122 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import {
+ reviewDuplicateClusterAction,
+ type DuplicateClusterDecision,
+ type ReviewDuplicateClusterResult,
+} from "../actions";
+import type { DuplicateClusterOverride } from "yt-dlp-transcript-common/lib/duplicates";
+
+type Status =
+ | { kind: "idle" }
+ | { kind: "running"; decision: DuplicateClusterDecision }
+ | { kind: "done"; result: ReviewDuplicateClusterResult }
+ | { kind: "error"; message: string };
+
+// NOTE ON THE aria-labels BELOW: none of them may contain the substring
+// "duplicate cluster <id>". That is the CARD's own label, and Playwright's
+// getByLabel matches by SUBSTRING, so a button labelled "confirm duplicate
+// cluster <id>" makes the card's own locator resolve to three elements and
+// every existing assertion on it dies with a strict-mode violation. That is
+// exactly what happened here, and this repo has now hit the same collision
+// three times (deploy-page's "Build & deploy" vs "Build & deploy all sites",
+// the settings placeholder collision, and this).
+export function DuplicateClusterReview({
+ clusterId,
+ override,
+}: {
+ clusterId: string;
+ override: DuplicateClusterOverride | undefined;
+}) {
+ const [status, setStatus] = useState<Status>({ kind: "idle" });
+ // Disabled until mounted, deliberately. A server-rendered button has no
+ // handler until React hydrates, so a click before then fires NOTHING — no
+ // request, no error, nothing to debug. That is the exact failure that made
+ // the digest pilot's job look "un-created", and /review renders a page
+ // heavy enough to hit the same window. See StreamActionLog.
+ const [mounted, setMounted] = useState(false);
+ useEffect(() => setMounted(true), []);
+
+ async function decide(decision: DuplicateClusterDecision) {
+ setStatus({ kind: "running", decision });
+ try {
+ setStatus({
+ kind: "done",
+ result: await reviewDuplicateClusterAction(clusterId, decision),
+ });
+ } catch (e) {
+ setStatus({ kind: "error", message: (e as Error).message });
+ }
+ }
+
+ const busy = status.kind === "running";
+ const disabled = busy || !mounted;
+ const confirmed = override?.confirmed === true;
+ const rejected = override?.notDuplicate === true;
+
+ return (
+ <div className="flex items-center gap-2 flex-wrap">
+ <button
+ type="button"
+ onClick={() => decide("confirmed")}
+ disabled={disabled || confirmed}
+ aria-label={`confirm cluster ${clusterId}`}
+ className="px-2 py-1 rounded-md border border-border text-xs font-medium hover:bg-muted disabled:opacity-50"
+ >
+ {busy && status.decision === "confirmed" ? "Confirming…" : "Confirm"}
+ </button>
+ <button
+ type="button"
+ onClick={() => decide("not-duplicate")}
+ disabled={disabled || rejected}
+ aria-label={`reject cluster ${clusterId}`}
+ className="px-2 py-1 rounded-md border border-border text-xs font-medium hover:bg-muted disabled:opacity-50"
+ >
+ {busy && status.decision === "not-duplicate" ? "Rejecting…" : "Not a duplicate"}
+ </button>
+ {(confirmed || rejected) && (
+ <button
+ type="button"
+ onClick={() => decide("clear")}
+ disabled={disabled}
+ aria-label={`clear cluster decision ${clusterId}`}
+ className="px-2 py-1 rounded-md text-xs text-muted-foreground underline hover:text-foreground disabled:opacity-50"
+ >
+ {busy && status.decision === "clear" ? "Clearing…" : "Undo"}
+ </button>
+ )}
+ {status.kind === "done" && status.result.ok && (
+ <span
+ aria-label={`cluster review result ${clusterId}`}
+ className="text-xs text-muted-foreground"
+ >
+ {status.result.shared > 0 || status.result.misaligned > 0
+ ? `${status.result.shared} digest(s) shared` +
+ (status.result.misaligned > 0
+ ? `, ${status.result.misaligned} misaligned`
+ : "")
+ : "Saved."}
+ </span>
+ )}
+ {status.kind === "done" && !status.result.ok && (
+ <span
+ role="alert"
+ aria-label={`cluster review error ${clusterId}`}
+ className="text-xs text-destructive"
+ >
+ {status.result.error}
+ </span>
+ )}
+ {status.kind === "error" && (
+ <span
+ role="alert"
+ aria-label={`cluster review error ${clusterId}`}
+ className="text-xs text-destructive"
+ >
+ {status.message}
+ </span>
+ )}
+ </div>
+ );
+}
diff --git a/editor/app/review/components/MediaScanFindingRow.tsx b/editor/app/review/components/MediaScanFindingRow.tsx
@@ -0,0 +1,121 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import Link from "next/link";
+import { formatBytes } from "yt-dlp-transcript-common/lib/format";
+import {
+ mediaScanKey,
+ type MediaScanFinding,
+} from "yt-dlp-transcript-common/lib/mediaScan";
+import { reviewMediaFindingAction } from "../actions";
+
+const VERDICT_LABEL: Record<MediaScanFinding["verdict"], string> = {
+ ok: "ok",
+ unreadable: "unreadable",
+ truncated: "truncated",
+ stray: "stray file",
+ unknown: "could not tell",
+};
+
+const VERDICT_HELP: Record<MediaScanFinding["verdict"], string> = {
+ ok: "",
+ unreadable:
+ "ffprobe could not parse the container at all. This file cannot be transcribed or diarized from.",
+ truncated:
+ "Decodes, but is materially shorter than the metadata says — the download was cut off.",
+ stray:
+ "Readable media that is not one of the app's own outputs: a leftover scratch or temp file taking up disk.",
+ unknown:
+ "The scan could not reach a conclusion. Nothing is claimed about this file.",
+};
+
+export function MediaScanFindingRow({
+ finding,
+ reviewed,
+}: {
+ finding: MediaScanFinding;
+ reviewed: boolean;
+}) {
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState<string | null>(null);
+ const key = mediaScanKey(finding);
+
+ function toggle() {
+ setError(null);
+ startTransition(async () => {
+ const r = await reviewMediaFindingAction(key, !reviewed);
+ if (!r.ok) setError(r.error);
+ });
+ }
+
+ return (
+ <li
+ aria-label={`media finding ${key}`}
+ data-verdict={finding.verdict}
+ className="border border-border rounded-md p-3 flex flex-col gap-1"
+ >
+ <div className="flex items-center gap-2 flex-wrap text-xs">
+ <span className="rounded px-2 py-0.5 border border-border font-medium">
+ {VERDICT_LABEL[finding.verdict]}
+ </span>
+ <span className="text-muted-foreground">
+ tier {finding.tier} · {formatBytes(finding.bytes)}
+ </span>
+ {reviewed && (
+ <span className="text-muted-foreground">· marked reviewed</span>
+ )}
+ </div>
+ <p className="text-sm">
+ <Link
+ href={`/channels/${encodeURIComponent(finding.channelSlug)}/videos/${encodeURIComponent(finding.videoId)}`}
+ className="underline underline-offset-2"
+ >
+ {finding.slug}
+ </Link>{" "}
+ <code className="text-xs">{finding.file}</code>
+ </p>
+ <p className="text-xs text-muted-foreground">
+ {VERDICT_HELP[finding.verdict]}
+ </p>
+ {(finding.containerSeconds !== null ||
+ finding.metadataSeconds !== null) && (
+ <p className="text-xs text-muted-foreground">
+ container{" "}
+ {finding.containerSeconds === null
+ ? "unreadable"
+ : `${Math.round(finding.containerSeconds)}s`}{" "}
+ · metadata{" "}
+ {finding.metadataSeconds === null
+ ? "unknown"
+ : `${Math.round(finding.metadataSeconds)}s`}
+ </p>
+ )}
+ {finding.detail && (
+ <p className="text-xs text-muted-foreground font-mono break-all">
+ {finding.detail}
+ </p>
+ )}
+ <div className="flex items-center gap-2">
+ {/* Deletion is deliberately NOT here. The convention in this app is
+ report first, delete second: the per-file delete already exists on
+ the video page, behind its own confirm, and a scan that could delete
+ what it flagged would be a very different and much more dangerous
+ thing than a scan. */}
+ <button
+ type="button"
+ onClick={toggle}
+ disabled={pending}
+ aria-label={`${reviewed ? "unreview" : "review"} ${key}`}
+ className="px-2 py-1 rounded border border-border text-xs hover:bg-muted disabled:opacity-50"
+ >
+ {reviewed ? "Un-review" : "Mark reviewed"}
+ </button>
+ {error && (
+ <span role="alert" className="text-xs text-destructive">
+ {error}
+ </span>
+ )}
+ </div>
+ </li>
+ );
+}
diff --git a/editor/app/review/components/RunDuplicateDetectionButton.tsx b/editor/app/review/components/RunDuplicateDetectionButton.tsx
@@ -0,0 +1,85 @@
+"use client";
+
+import { useState } from "react";
+import {
+ runDuplicateDetectionAction,
+ type DuplicateScope,
+ type RunDuplicateDetectionResult,
+} from "../actions";
+
+type Status =
+ | { kind: "idle" }
+ | { kind: "running" }
+ | { kind: "done"; result: RunDuplicateDetectionResult }
+ | { kind: "error"; message: string };
+
+export function RunDuplicateDetectionButton() {
+ const [scope, setScope] = useState<DuplicateScope>("shorts");
+ const [status, setStatus] = useState<Status>({ kind: "idle" });
+
+ async function handleClick() {
+ setStatus({ kind: "running" });
+ try {
+ const result = await runDuplicateDetectionAction(scope);
+ setStatus({ kind: "done", result });
+ } catch (e) {
+ setStatus({ kind: "error", message: (e as Error).message });
+ }
+ }
+
+ const running = status.kind === "running";
+ return (
+ <div className="flex items-center gap-2">
+ <label className="sr-only" htmlFor="duplicate-scope">
+ Detection scope
+ </label>
+ <select
+ id="duplicate-scope"
+ aria-label="duplicate detection scope"
+ value={scope}
+ onChange={(e) => setScope(e.target.value as DuplicateScope)}
+ disabled={running}
+ className="text-sm rounded-md border border-border bg-transparent px-2 py-2"
+ >
+ <option value="shorts">Shorts only</option>
+ <option value="all">All durations</option>
+ </select>
+ <button
+ type="button"
+ onClick={handleClick}
+ disabled={running}
+ aria-label="detect duplicate shorts"
+ className="px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50"
+ >
+ {running ? "Detecting…" : "Detect duplicates"}
+ </button>
+ {status.kind === "done" && status.result.ok && (
+ <span
+ aria-label="detect duplicate shorts result"
+ className="text-xs text-muted-foreground"
+ >
+ {status.result.clusters} cluster(s) ·{" "}
+ {status.result.videosInClusters} video(s)
+ </span>
+ )}
+ {status.kind === "done" && !status.result.ok && (
+ <span
+ role="alert"
+ aria-label="detect duplicate shorts error"
+ className="text-xs text-destructive"
+ >
+ {status.result.error}
+ </span>
+ )}
+ {status.kind === "error" && (
+ <span
+ role="alert"
+ aria-label="detect duplicate shorts error"
+ className="text-xs text-destructive"
+ >
+ {status.message}
+ </span>
+ )}
+ </div>
+ );
+}
diff --git a/editor/app/review/components/RunMediaScanButton.tsx b/editor/app/review/components/RunMediaScanButton.tsx
@@ -0,0 +1,93 @@
+"use client";
+
+import { useState } from "react";
+import {
+ runMediaScanAction,
+ type RunMediaScanResult,
+} from "../actions";
+
+type Status =
+ | { kind: "idle" }
+ | { kind: "running" }
+ | { kind: "done"; result: RunMediaScanResult }
+ | { kind: "error"; message: string };
+
+// The depth choice is the whole reason this has a select rather than one button.
+// The container read costs milliseconds per file; the full decode is a real
+// transcode of every suspect file, so it has to be asked for rather than
+// happening because someone clicked the obvious thing.
+type Depth = "quick" | "deep";
+
+export function RunMediaScanButton({ slug }: { slug?: string }) {
+ const [depth, setDepth] = useState<Depth>("quick");
+ const [status, setStatus] = useState<Status>({ kind: "idle" });
+
+ async function handleClick() {
+ setStatus({ kind: "running" });
+ try {
+ const result = await runMediaScanAction({
+ ...(slug ? { channels: [slug] } : {}),
+ deepProbe: depth === "deep",
+ });
+ setStatus({ kind: "done", result });
+ } catch (e) {
+ setStatus({ kind: "error", message: (e as Error).message });
+ }
+ }
+
+ const running = status.kind === "running";
+ return (
+ <div className="flex items-center gap-2 flex-wrap">
+ <label className="sr-only" htmlFor={`media-scan-depth-${slug ?? "all"}`}>
+ Scan depth
+ </label>
+ <select
+ id={`media-scan-depth-${slug ?? "all"}`}
+ aria-label="media scan depth"
+ value={depth}
+ onChange={(e) => setDepth(e.target.value as Depth)}
+ disabled={running}
+ className="text-sm rounded-md border border-border bg-transparent px-2 py-2"
+ >
+ <option value="quick">Quick (read containers)</option>
+ <option value="deep">Deep (decode suspect files)</option>
+ </select>
+ <button
+ type="button"
+ onClick={handleClick}
+ disabled={running}
+ aria-label={slug ? "scan channel media" : "scan media for corruption"}
+ className="px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50"
+ >
+ {running ? "Scanning…" : slug ? "Scan this channel" : "Scan media"}
+ </button>
+ {status.kind === "done" && status.result.ok && (
+ <span
+ aria-label="media scan result"
+ className="text-xs text-muted-foreground"
+ >
+ {status.result.filesScanned.toLocaleString()} file(s) checked ·{" "}
+ {status.result.findings.toLocaleString()} finding(s)
+ </span>
+ )}
+ {status.kind === "done" && !status.result.ok && (
+ <span
+ role="alert"
+ aria-label="media scan error"
+ className="text-xs text-destructive"
+ >
+ {status.result.error}
+ </span>
+ )}
+ {status.kind === "error" && (
+ <span
+ role="alert"
+ aria-label="media scan error"
+ className="text-xs text-destructive"
+ >
+ {status.message}
+ </span>
+ )}
+ </div>
+ );
+}
diff --git a/editor/app/review/lib/loadReview.ts b/editor/app/review/lib/loadReview.ts
@@ -0,0 +1,47 @@
+import type { Paths } from "yt-dlp-transcript-common/lib/paths";
+import { readDuplicateOverrides } from "yt-dlp-transcript-common/controller/duplicateShorts";
+import type {
+ DuplicateOverrides,
+ DuplicateReport,
+} from "yt-dlp-transcript-common/lib/duplicates";
+import type {
+ MediaScanOverrides,
+ MediaScanReport,
+} from "yt-dlp-transcript-common/lib/mediaScan";
+import {
+ readMediaScanOverrides,
+ readMediaScanReport,
+} from "yt-dlp-transcript-common/controller/scanCorruptMedia";
+import { getDuplicateReport } from "../../lib/requestCache";
+
+// The half of the old actionable summary that is a HUMAN judgement rather than
+// a lane's work: findings someone decides about, with the decisions kept beside
+// them. Split out of loadActionable because nothing but the review page ever
+// read these four — and the dashboard and the widget poll were paying the
+// 6.7 MB duplicates parse on every render for a list they never showed.
+export type ReviewSummary = {
+ duplicates: DuplicateReport | null;
+ // The human decisions kept alongside the report — a cluster's canonical
+ // choice, "not a duplicate", and the `confirmed` flag that is the only thing
+ // letting a needsReview cluster share derived work. Loaded here because the
+ // review UI cannot show what has already been decided without it, and a
+ // review queue that forgets its own answers re-asks every question.
+ duplicateOverrides: DuplicateOverrides;
+ // The corrupt-media scan, and the "reviewed, this one's fine" decisions kept
+ // beside it. Null when the scan has never been run — which is NOT the same as
+ // "nothing is wrong", and the section says so rather than rendering an
+ // all-clear it has no evidence for.
+ mediaScan: MediaScanReport | null;
+ mediaScanOverrides: MediaScanOverrides;
+};
+
+export async function loadReviewSummary(paths: Paths): Promise<ReviewSummary> {
+ const [duplicates, duplicateOverrides, mediaScan, mediaScanOverrides] =
+ await Promise.all([
+ getDuplicateReport(paths),
+ readDuplicateOverrides(paths),
+ readMediaScanReport(paths),
+ readMediaScanOverrides(paths),
+ ]);
+ return { duplicates, duplicateOverrides, mediaScan, mediaScanOverrides };
+}
diff --git a/editor/app/review/page.tsx b/editor/app/review/page.tsx
@@ -0,0 +1,248 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import {
+ mediaScanKey,
+ mediaScanTotals,
+ unreviewedFindings,
+ type MediaScanOverrides,
+ type MediaScanReport,
+} from "yt-dlp-transcript-common/lib/mediaScan";
+import type {
+ DuplicateCluster,
+ DuplicateOverrides,
+ DuplicateReport,
+} from "yt-dlp-transcript-common/lib/duplicates";
+import { loadReviewSummary } from "./lib/loadReview";
+import { RunDuplicateDetectionButton } from "./components/RunDuplicateDetectionButton";
+import { RunMediaScanButton } from "./components/RunMediaScanButton";
+import { MediaScanFindingRow } from "./components/MediaScanFindingRow";
+import { DuplicateClusterReview } from "./components/DuplicateClusterReview";
+
+export const dynamic = "force-dynamic";
+
+export const metadata: Metadata = { title: "Review" };
+
+// CORPUS REVIEW: the two findings a PERSON decides about. Duplicate clusters
+// and media-integrity findings are both flag-only, both carry a reviewed state,
+// and neither is an operation or a pile of reclaimable bytes — which is why
+// they are here rather than on an operation page or on /cleanup.
+export default async function ReviewPage() {
+ const review = await loadReviewSummary(getPaths());
+ return (
+ <div className="flex flex-col gap-6">
+ <h1 className="text-2xl font-semibold">Review</h1>
+ <p className="text-sm text-muted-foreground">
+ Corpus review — findings a human decides, not work a lane runs.
+ </p>
+ <DuplicatesSection
+ report={review.duplicates}
+ overrides={review.duplicateOverrides}
+ />
+ <MediaScanSection
+ report={review.mediaScan}
+ overrides={review.mediaScanOverrides}
+ />
+ </div>
+ );
+}
+
+// Corrupt media already on disk. Flag-only, exactly like the duplicates section
+// above it — the probes have existed since the audio-checked download landed;
+// what was missing was ever running them against files that are already here.
+function MediaScanSection({
+ report,
+ overrides,
+}: {
+ report: MediaScanReport | null;
+ overrides: MediaScanOverrides;
+}) {
+ const findings = report?.findings ?? [];
+ const outstanding = unreviewedFindings(report, overrides);
+ const totals = report ? mediaScanTotals(report) : null;
+ return (
+ <section aria-label="media scan" className="flex flex-col gap-2">
+ <div className="flex items-center justify-between flex-wrap gap-2">
+ <h2 className="text-lg font-semibold">
+ Media integrity{outstanding.length > 0 ? ` (${outstanding.length})` : ""}
+ </h2>
+ <RunMediaScanButton />
+ </div>
+ <p className="text-sm text-muted-foreground">
+ Checks the audio and video files still on disk for corruption or
+ truncation. Flag-only — nothing here deletes anything.
+ {totals && totals.scannedAt ? (
+ <>
+ {" "}
+ Last scan{" "}
+ <time dateTime={totals.scannedAt}>
+ {new Date(totals.scannedAt).toLocaleString()}
+ </time>{" "}
+ · {totals.filesScanned.toLocaleString()} file(s) across{" "}
+ {totals.videosScanned.toLocaleString()} video dir(s)
+ {totals.deepProbed > 0
+ ? `, ${totals.deepProbed.toLocaleString()} fully decoded`
+ : ""}
+ .
+ </>
+ ) : (
+ " Never run."
+ )}
+ </p>
+ {findings.length === 0 ? (
+ <p
+ aria-label="media scan empty"
+ className="text-sm text-muted-foreground border border-dashed border-border rounded p-4"
+ >
+ {/* "Never run" is NOT "all clear", and saying so would be the one way
+ this section could actively mislead. */}
+ {report
+ ? "Every file the scan read was intact."
+ : "Run a scan to check the media on disk."}
+ </p>
+ ) : (
+ <ul className="flex flex-col gap-3">
+ {findings.map((f) => (
+ <MediaScanFindingRow
+ key={mediaScanKey(f)}
+ finding={f}
+ reviewed={Boolean(overrides.reviewed[mediaScanKey(f)])}
+ />
+ ))}
+ </ul>
+ )}
+ </section>
+ );
+}
+
+function DuplicatesSection({
+ report,
+ overrides,
+}: {
+ report: DuplicateReport | null;
+ overrides: DuplicateOverrides;
+}) {
+ const clusters = report?.clusters ?? [];
+ return (
+ <section aria-label="duplicate-shorts" className="flex flex-col gap-2">
+ <div className="flex items-center justify-between flex-wrap gap-2">
+ <h2 className="text-lg font-semibold">Duplicate shorts</h2>
+ <RunDuplicateDetectionButton />
+ </div>
+ <p className="text-sm text-muted-foreground">
+ Cross-platform, cross-channel duplicate detection (metadata pre-cluster →
+ transcript comparison). Flag-only — review and act manually.
+ {report ? (
+ <>
+ {" "}
+ Last run{" "}
+ <time dateTime={report.generatedAt}>
+ {new Date(report.generatedAt).toLocaleString()}
+ </time>{" "}
+ ·{" "}
+ {report.runConfig.thresholdSeconds === null
+ ? "all durations"
+ : `≤ ${report.runConfig.thresholdSeconds}s`}
+ .
+ </>
+ ) : (
+ " Never run."
+ )}
+ </p>
+ {clusters.length === 0 ? (
+ <p
+ aria-label="duplicate-shorts empty"
+ className="text-sm text-muted-foreground border border-dashed border-border rounded p-4"
+ >
+ {report ? "No duplicate clusters found." : "Run detection to scan."}
+ </p>
+ ) : (
+ <ul className="flex flex-col gap-3">
+ {clusters.map((cluster) => (
+ <DuplicateClusterCard
+ key={cluster.clusterId}
+ cluster={cluster}
+ override={overrides.clusters[cluster.clusterId]}
+ />
+ ))}
+ </ul>
+ )}
+ </section>
+ );
+}
+
+function DuplicateClusterCard({
+ cluster,
+ override,
+}: {
+ cluster: DuplicateCluster;
+ override: DuplicateOverrides["clusters"][string] | undefined;
+}) {
+ const matchLabel: Record<DuplicateCluster["matchKind"], string> = {
+ "transcript-exact": "exact transcript",
+ "transcript-near": "near transcript",
+ "title-duration": "title + runtime",
+ };
+ return (
+ <li
+ aria-label={`duplicate cluster ${cluster.clusterId}`}
+ className="border border-border rounded-md p-3 flex flex-col gap-2"
+ >
+ <div className="flex items-center gap-2 flex-wrap text-xs">
+ <Badge>{matchLabel[cluster.matchKind]}</Badge>
+ {/* Nothing compared these videos' content — one side has no transcript.
+ The cluster is a suspect for a human, stays out of the built site,
+ and shares no derived work until someone confirms it. */}
+ {cluster.needsReview && !override?.confirmed && !override?.notDuplicate && (
+ <Badge>needs review</Badge>
+ )}
+ {override?.confirmed && <Badge>confirmed</Badge>}
+ {override?.notDuplicate && <Badge>not a duplicate</Badge>}
+ {cluster.score !== null && (
+ <Badge>score {cluster.score.toFixed(2)}</Badge>
+ )}
+ {cluster.contained && <Badge>clip-of-longer</Badge>}
+ {cluster.crossPlatform && <Badge>cross-platform</Badge>}
+ {cluster.crossChannel && <Badge>cross-channel</Badge>}
+ <span className="text-muted-foreground">
+ {cluster.videoRefs.length} videos · ~{cluster.durationBucket}s
+ </span>
+ {/* The confirm path exists precisely for `needsReview` clusters:
+ clusterMaySharePartial fails closed, so until a human says
+ "confirmed" these share no digest and never reach a built site.
+ Content-confirmed clusters need no confirmation — but they can still
+ be rejected, which is the only way to un-assert a wrong one. */}
+ <span className="ml-auto">
+ <DuplicateClusterReview
+ clusterId={cluster.clusterId}
+ override={override}
+ />
+ </span>
+ </div>
+ <ul className="flex flex-col gap-1">
+ {cluster.videoRefs.map((ref) => (
+ <li key={ref.slug} className="text-sm flex items-baseline gap-2 flex-wrap">
+ <Link
+ href={`/channels/${ref.channelSlug}/videos/${ref.id}`}
+ className="underline hover:text-foreground"
+ >
+ {ref.title || ref.slug}
+ </Link>
+ <span className="text-xs text-muted-foreground font-mono">
+ {ref.platform} · {ref.channelSlug} · {ref.uploadDate}
+ {ref.hasTranscript ? "" : " · no transcript"}
+ </span>
+ </li>
+ ))}
+ </ul>
+ </li>
+ );
+}
+
+function Badge({ children }: { children: React.ReactNode }) {
+ return (
+ <span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-muted-foreground">
+ {children}
+ </span>
+ );
+}
diff --git a/editor/e2e/duplicate-shorts.spec.ts b/editor/e2e/duplicate-shorts.spec.ts
@@ -6,7 +6,7 @@ import { readJson, resetData, resolvePath, writeSite } from "./helpers";
// whose transcripts are near-identical across platforms/channels, plus a unique
// short, a metadata-only pair (one missing its transcript), and a long video
// that contains a short's transcript (for the all-durations containment case).
-// Drives Build index + Build stats, then runs detection from /actionable and
+// Drives Build index + Build stats, then runs detection from /review and
// asserts on transcripts/duplicates.json.
type DuplicateRef = {
@@ -204,7 +204,7 @@ test("clusters cross-platform near-duplicate shorts (incl. plain VTT) and ignore
});
await buildData(page);
- await page.goto("/actionable");
+ await page.goto("/review");
await page.getByLabel("duplicate detection scope").selectOption("shorts");
await page.getByRole("button", { name: "detect duplicate shorts" }).click();
await expect(page.getByLabel("detect duplicate shorts result")).toBeVisible({
@@ -284,7 +284,7 @@ test("all-durations run pulls a long video into the short's cluster via containm
});
await buildData(page);
- await page.goto("/actionable");
+ await page.goto("/review");
await page.getByLabel("duplicate detection scope").selectOption("all");
await page.getByRole("button", { name: "detect duplicate shorts" }).click();
await expect(page.getByLabel("detect duplicate shorts result")).toBeVisible({
@@ -339,7 +339,7 @@ test("title blocking confirms matching content, rejects differing content, and q
await buildData(page);
// Scope "all" runs corpus-wide, where the default blocking strategy is title.
- await page.goto("/actionable");
+ await page.goto("/review");
await page.getByLabel("duplicate detection scope").selectOption("all");
await page.getByRole("button", { name: "detect duplicate shorts" }).click();
await expect(page.getByLabel("detect duplicate shorts result")).toBeVisible({
diff --git a/editor/e2e/review.spec.ts b/editor/e2e/review.spec.ts
@@ -0,0 +1,36 @@
+import { test, expect } from "@playwright/test";
+import { resetData } from "./helpers";
+
+// /review is the page for the findings a PERSON decides about: duplicate
+// clusters and media-integrity findings. Both are flag-only and both carry a
+// reviewed state, which is what makes them neither an operation nor bytes.
+//
+// duplicate-shorts.spec.ts drives the duplicates half end to end. This file is
+// the page itself, and the empty states — the media scan has never had an e2e
+// at all, and "never run" is the one thing that section must not render as
+// "all clear".
+
+test("/review renders both sections and their empty states", async ({
+ page,
+}) => {
+ await resetData("empty");
+ await page.goto("/review");
+ await expect(
+ page.getByRole("heading", { level: 1, name: "Review" }),
+ ).toBeVisible();
+
+ await expect(page.getByLabel("duplicate-shorts empty")).toContainText(
+ "Run detection to scan.",
+ );
+ await expect(page.getByLabel("media scan empty")).toContainText(
+ "Run a scan to check the media on disk.",
+ );
+});
+
+test("Review is in the Corpus group of the sidebar", async ({ page }) => {
+ await resetData("empty");
+ await page.goto("/");
+ await expect(
+ page.locator("aside").getByRole("link", { name: /^Review$/ }),
+ ).toHaveAttribute("href", "/review");
+});