// Client-safe types and constants for the corrupt-media scan. The walk itself // lives in controller/scanCorruptMedia.ts; this file is what a page or a widget // may import. // // MODELLED ON lib/duplicates.ts, deliberately: a versioned report JSON written // atomically at the corpus root, findings that mutate nothing, and human review // decisions kept in a SIBLING overrides file rather than in the report — because // the report is regenerated wholesale by every run and a decision recorded // inside it would be destroyed by the next one. export const MEDIA_SCAN_REPORT_VERSION = 1; export const MEDIA_SCAN_FILENAME = "media-scan.json"; export const MEDIA_SCAN_OVERRIDES_FILENAME = "media-scan.overrides.json"; export const MEDIA_SCAN_OVERRIDES_VERSION = 1; // How far apart the container's own duration and the metadata's may be before // the file is treated as suspect. Generous: a container rounds, and yt-dlp's // metadata duration comes from the site rather than from the bytes. export const DEFAULT_DURATION_TOLERANCE_SEC = 30; // A relative tolerance too, because 30s means something very different on a // 40-second clip than on an eight-hour stream. export const DEFAULT_DURATION_TOLERANCE_RATIO = 0.02; // What the scan concluded about one file. // // ok — readable, and its length agrees with what we know. // unreadable — ffprobe RAN and could not parse the container. This is the // today's-known-case verdict: a truncated mp4 fails with // "moov atom not found" in milliseconds. // truncated — readable, but materially shorter than the metadata says. Only // reachable with the deep probe, because ffmpeg exits 0 on a // truncated container (see ffmpegStreamProbe.ts) so a duration // comparison is the ONLY way to catch it. // stray — a media file the app does not recognize as one of its own // outputs: a leftover scratch or temp file. Not a corruption // verdict at all, and reported separately for that reason — but // it is how the 4 GiB source-media.temp.mp4 becomes visible. // unknown — the scan could not reach a conclusion. ffprobe missing, a // probe error, no duration to compare against. NEVER acted on. export type MediaScanVerdict = | "ok" | "unreadable" | "truncated" | "stray" | "unknown"; // The verdicts a human is being asked to look at. `ok` files are not written to // the report at all — the report is a work list, not an inventory. export const MEDIA_SCAN_FINDING_VERDICTS: ReadonlyArray = [ "unreadable", "truncated", "stray", "unknown", ]; export type MediaScanFinding = { // `${channelSlug}/${videoId}`, the slug the rest of the app uses. slug: string; channelSlug: string; videoId: string; // Filename within the video dir. file: string; bytes: number; verdict: MediaScanVerdict; // Which tier reached the verdict. 1 is the cheap container read every file // gets; 2 is the full decode, which is opt-in and narrowed. tier: 1 | 2; // The container's own duration, and what metadata.info.json claims. Either may // be null — that is what an `unknown` usually means. containerSeconds: number | null; metadataSeconds: number | null; // First meaningful line of probe output. The evidence, so a verdict can be // argued with rather than merely trusted. detail?: string; }; export type MediaScanChannelTotals = { videosScanned: number; filesScanned: number; ok: number; unreadable: number; truncated: number; stray: number; unknown: number; // Files that got the tier-2 full decode. deepProbed: number; bytesAtRisk: number; scannedAt: string; }; export type MediaScanReport = { version: number; generatedAt: string; runConfig: { deepProbe: boolean; toleranceSeconds: number; toleranceRatio: number; }; // PER CHANNEL, not one flat set of totals. A per-channel re-scan replaces one // entry and its findings; the corpus-wide numbers are then the sum of these, // so a partial run can never leave the headline claiming to describe a scan it // did not do. channels: Record; findings: MediaScanFinding[]; }; // Corpus-wide totals, derived rather than stored — see MediaScanReport.channels. export function mediaScanTotals(report: MediaScanReport): MediaScanChannelTotals { const out: MediaScanChannelTotals = { videosScanned: 0, filesScanned: 0, ok: 0, unreadable: 0, truncated: 0, stray: 0, unknown: 0, deepProbed: 0, bytesAtRisk: 0, scannedAt: "", }; for (const t of Object.values(report.channels ?? {})) { out.videosScanned += t.videosScanned; out.filesScanned += t.filesScanned; out.ok += t.ok; out.unreadable += t.unreadable; out.truncated += t.truncated; out.stray += t.stray; out.unknown += t.unknown; out.deepProbed += t.deepProbed; out.bytesAtRisk += t.bytesAtRisk; if (t.scannedAt > out.scannedAt) out.scannedAt = t.scannedAt; } return out; } // A finding's stable identity across runs, for the overrides file. export function mediaScanKey(f: { slug: string; file: string; }): string { return `${f.slug}/${f.file}`; } // "Looked at it; it is fine." Kept OUT of the report for the reason stated in // the header. export type MediaScanOverride = { reviewedAt: string; note?: string; }; export type MediaScanOverrides = { version: number; reviewed: Record; }; // Never throws. A malformed overrides file reads as "no decisions recorded", so // the page still renders rather than 500ing on a hand-edited file. export function sanitizeMediaScanOverrides(value: unknown): MediaScanOverrides { const empty: MediaScanOverrides = { version: MEDIA_SCAN_OVERRIDES_VERSION, reviewed: {}, }; if (!value || typeof value !== "object") return empty; const raw = value as { reviewed?: unknown }; if (!raw.reviewed || typeof raw.reviewed !== "object") return empty; const reviewed: Record = {}; for (const [key, v] of Object.entries(raw.reviewed as object)) { if (!v || typeof v !== "object") continue; const o = v as { reviewedAt?: unknown; note?: unknown }; if (typeof o.reviewedAt !== "string") continue; reviewed[key] = { reviewedAt: o.reviewedAt, ...(typeof o.note === "string" && o.note ? { note: o.note } : {}), }; } return { version: MEDIA_SCAN_OVERRIDES_VERSION, reviewed }; } // Findings a human has not already dismissed. What every surface should count. export function unreviewedFindings( report: MediaScanReport | null, overrides: MediaScanOverrides | null, ): MediaScanFinding[] { if (!report) return []; const reviewed = overrides?.reviewed ?? {}; return report.findings.filter((f) => !reviewed[mediaScanKey(f)]); }