Archilyzer · Source

archilyzer

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

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:
Deditor/app/actionable/components/DuplicateClusterReview.tsx | 122-------------------------------------------------------------------------------
Deditor/app/actionable/components/MediaScanFindingRow.tsx | 121-------------------------------------------------------------------------------
Deditor/app/actionable/components/RunDuplicateDetectionButton.tsx | 85-------------------------------------------------------------------------------
Deditor/app/actionable/components/RunMediaScanButton.tsx | 93-------------------------------------------------------------------------------
Meditor/app/actionable/page.tsx | 231+------------------------------------------------------------------------------
Meditor/app/channels/[slug]/components/stages/DiagnosticsStage.tsx | 2+-
Meditor/app/components/CommandPalette.tsx | 2+-
Meditor/app/lib/actionable/actions.ts | 186+++----------------------------------------------------------------------------
Meditor/app/lib/nav.ts | 7+++++++
Deditor/app/lib/review/loadReview.ts | 47-----------------------------------------------
Aeditor/app/review/actions.ts | 189+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/review/components/DuplicateClusterReview.tsx | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/review/components/MediaScanFindingRow.tsx | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/review/components/RunDuplicateDetectionButton.tsx | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/review/components/RunMediaScanButton.tsx | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/review/lib/loadReview.ts | 47+++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/review/page.tsx | 248+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/e2e/duplicate-shorts.spec.ts | 8++++----
Aeditor/e2e/review.spec.ts | 36++++++++++++++++++++++++++++++++++++
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"); +});