@clipy/cli
Advanced tools
| export class ImportError extends Error { | ||
| code; | ||
| remediation; | ||
| /** What survived, when something did. Non-null makes this a partial failure. */ | ||
| partial; | ||
| constructor(code, message, remediation, partial = null) { | ||
| super(message); | ||
| this.name = "ImportError"; | ||
| this.code = code; | ||
| this.remediation = remediation; | ||
| this.partial = partial; | ||
| } | ||
| } | ||
| export function isImportError(e) { | ||
| return e instanceof Error && e.name === "ImportError"; | ||
| } | ||
| /** | ||
| * The one JSON object a failed `--json` run prints. Exactly one object per run, | ||
| * success or failure, so an agent can always `JSON.parse` stdout. | ||
| */ | ||
| export function errorEnvelope(e) { | ||
| if (isImportError(e)) { | ||
| return { | ||
| ok: false, | ||
| code: e.code, | ||
| error: e.message, | ||
| remediation: e.remediation, | ||
| partial: e.partial, | ||
| }; | ||
| } | ||
| return { | ||
| ok: false, | ||
| code: "unknown", | ||
| error: e instanceof Error ? e.message : String(e), | ||
| remediation: "Re-run the command; if it fails again, report this output as a bug.", | ||
| partial: null, | ||
| }; | ||
| } |
| /** | ||
| * Which yt-dlp failures are worth trying again. | ||
| * | ||
| * YouTube hands out media URLs that expire, and it throttles bursts. Both show | ||
| * up as an HTTP 403 on a command that succeeds verbatim a few seconds later — | ||
| * so a single attempt turns a hiccup into "frames could not be extracted". | ||
| * Against that, a genuinely unavailable video must fail on the first try: | ||
| * retrying a private video three times just makes the user wait longer for the | ||
| * same answer. | ||
| */ | ||
| /** | ||
| * Failures no client change or retry can fix. Checked FIRST, ahead of the 403 | ||
| * test, because YouTube sometimes wraps these in a 403 — and retrying a bot | ||
| * check or an age gate burns the user's IP reputation for nothing. | ||
| */ | ||
| const FATAL_MARKERS = [ | ||
| /Sign in to confirm you.?re not a bot/i, | ||
| /Sign in to confirm your age/i, | ||
| /This video is unavailable/i, | ||
| /Video unavailable/i, | ||
| /Private video/i, | ||
| /members-only/i, | ||
| /age.?restricted/i, | ||
| ]; | ||
| const FORBIDDEN = /HTTP Error 403|\b403 Forbidden\b/i; | ||
| const TRANSIENT = [ | ||
| /HTTP Error 5\d\d/i, | ||
| /HTTP Error 429/i, | ||
| /\btimed out\b/i, | ||
| /\btimeout\b/i, | ||
| /connection reset/i, | ||
| /ECONNRESET|ETIMEDOUT|EAI_AGAIN|ENETUNREACH/, | ||
| /temporary failure/i, | ||
| /unable to connect/i, | ||
| /remote end closed connection/i, | ||
| ]; | ||
| /** | ||
| * Classifies yt-dlp's stderr. Only ever called on yt-dlp's OWN output — our | ||
| * local aborts (the size ceiling, our 20-minute cap) are fatal by construction | ||
| * and must not be routed through here, or a slow link would be retried for an | ||
| * hour. | ||
| */ | ||
| export function classifyFailure(stderr) { | ||
| if (FATAL_MARKERS.some((re) => re.test(stderr))) | ||
| return "fatal"; | ||
| if (FORBIDDEN.test(stderr)) | ||
| return "forbidden"; | ||
| if (TRANSIENT.some((re) => re.test(stderr))) | ||
| return "transient"; | ||
| return "fatal"; | ||
| } | ||
| export function isRetriable(kind) { | ||
| return kind !== "fatal"; | ||
| } | ||
| /** | ||
| * A stale binary and a transient 403 look nothing alike once you know where to | ||
| * look: a 403 fails mid-transfer with formats already resolved, while an | ||
| * outdated yt-dlp fails during EXTRACTION — it cannot solve the signature | ||
| * challenge or is being forced onto SABR. Only the second is fixed by updating, | ||
| * so only the second should suggest it. | ||
| */ | ||
| const STALE_BINARY_MARKERS = [ | ||
| /unable to extract/i, | ||
| /Only images are available/i, | ||
| /Signature extraction failed/i, | ||
| /n challenge/i, | ||
| /nsig extraction failed/i, | ||
| /forcing SABR/i, | ||
| /Requested format is not available/i, | ||
| /no video formats found/i, | ||
| ]; | ||
| export function looksLikeStaleBinary(stderr) { | ||
| return STALE_BINARY_MARKERS.some((re) => re.test(stderr)); | ||
| } | ||
| /** How a retry is announced, so every caller says it the same way. */ | ||
| export function retryNotice(stderr, kind, attempt, total) { | ||
| const what = kind === "forbidden" | ||
| ? "Download blocked (HTTP 403)" | ||
| : `Download failed (${firstUsefulLine(stderr)})`; | ||
| return `${what} — retrying (${attempt}/${total})…`; | ||
| } | ||
| function firstUsefulLine(stderr) { | ||
| const line = stderr.trim().split("\n").filter(Boolean).pop() ?? "unknown error"; | ||
| return line.replace(/^ERROR:\s*/i, "").slice(0, 120); | ||
| } | ||
| export function sleep(ms) { | ||
| return new Promise((r) => setTimeout(r, ms)); | ||
| } | ||
| /** Backoff between attempts 1→2 and 2→3. The observed recovery window for an | ||
| * expired media URL is "a minute later", so these are deliberately unhurried. */ | ||
| export const RETRY_BACKOFF_MS = [3_000, 8_000]; | ||
| export const MAX_ATTEMPTS = 3; | ||
| /** | ||
| * On every attempt. Letting yt-dlp retry a mid-transfer 403 in-process is far | ||
| * cheaper than re-running the binary, and pacing the extraction burst is what | ||
| * keeps a residential IP from reading as automation. Concurrency stays at 1 — | ||
| * we fetch one bounded rendition once, so there is nothing to win by raising it | ||
| * and a rate-limit to lose. | ||
| */ | ||
| export const BASELINE_DOWNLOAD_ARGS = [ | ||
| "--retries", "5", | ||
| "--fragment-retries", "5", | ||
| "--extractor-retries", "3", | ||
| "--retry-sleep", "http:exp=1:30", | ||
| "--retry-sleep", "fragment:exp=1:30", | ||
| "--sleep-requests", "0.75", | ||
| "--concurrent-fragments", "1", | ||
| ]; | ||
| /** | ||
| * The client fallback, used only after the defaults have failed repeatedly. | ||
| * | ||
| * These three need no PO token and (for tv/android_vr) no JS runtime, unlike | ||
| * `android`, which is PO-token-gated and is NOT in yt-dlp's default set — | ||
| * falling back to it moves away from token-free clients rather than toward | ||
| * them. See docs/research/2026-07-29-ytdlp-403-fallbacks.md §2. | ||
| */ | ||
| export const TOKEN_FREE_CLIENT_ARGS = [ | ||
| "--extractor-args", | ||
| "youtube:player_client=tv,android_vr,web_embedded", | ||
| ]; | ||
| /** | ||
| * Last resort: a pre-muxed progressive file is served as one conventional HTTP | ||
| * download, sidestepping both adaptive-fragment 403s and SABR. Format 18 is | ||
| * 360p — worse than our 720p target, and far better than no frames at all. | ||
| */ | ||
| export const PROGRESSIVE_FALLBACK = { | ||
| format: "b[height<=720]/18", | ||
| extraArgs: ["--extractor-args", "youtube:player_client=tv,android_vr"], | ||
| }; | ||
| export const DEFAULT_FORMAT = "bv*[height<=720]/b[height<=720]"; |
| /** | ||
| * YouTube URL → video id, tolerantly. | ||
| * | ||
| * The motivating failure: zsh users copy a URL and escape it, so the CLI is | ||
| * handed `https://www.youtube.com/watch\?v\=77FB-LS0Bjk` with literal | ||
| * backslashes. Passed through verbatim, yt-dlp does not error on that — it | ||
| * resolves it to a placeholder video ("Press Subscribe to continue", 0:00) and | ||
| * the user gets a bundle for a video they never asked for. So nothing raw ever | ||
| * reaches yt-dlp: we extract the id and rebuild the canonical URL ourselves. | ||
| */ | ||
| /** YouTube ids are exactly 11 chars of base64url. */ | ||
| const VIDEO_ID = /^[A-Za-z0-9_-]{11}$/; | ||
| /** Shell escaping and stray whitespace are noise, never meaning, in a URL. */ | ||
| function deEscape(raw) { | ||
| return raw.trim().replace(/\\/g, "").replace(/\s+/g, ""); | ||
| } | ||
| const YOUTUBE_HOSTS = new Set([ | ||
| "youtube.com", | ||
| "www.youtube.com", | ||
| "m.youtube.com", | ||
| "music.youtube.com", | ||
| "youtube-nocookie.com", | ||
| "www.youtube-nocookie.com", | ||
| "youtu.be", | ||
| "www.youtu.be", | ||
| ]); | ||
| /** The path forms that carry the id as their first segment. */ | ||
| const PATH_PREFIXES = ["shorts", "live", "embed", "v"]; | ||
| export function isYoutubeHost(host) { | ||
| return YOUTUBE_HOSTS.has(host.toLowerCase()); | ||
| } | ||
| /** | ||
| * Returns the 11-char video id, or null if this is not a recognisable | ||
| * single-video YouTube URL (a channel, a playlist-only link, or garbage). | ||
| */ | ||
| export function parseYoutubeId(raw) { | ||
| const cleaned = deEscape(raw); | ||
| let url = null; | ||
| try { | ||
| url = new URL(cleaned); | ||
| } | ||
| catch { | ||
| url = null; | ||
| } | ||
| if (url && isYoutubeHost(url.hostname)) { | ||
| const v = url.searchParams.get("v"); | ||
| if (v && VIDEO_ID.test(v)) | ||
| return v; | ||
| const segments = url.pathname.split("/").filter(Boolean); | ||
| if (url.hostname.toLowerCase().endsWith("youtu.be")) { | ||
| if (segments[0] && VIDEO_ID.test(segments[0])) | ||
| return segments[0]; | ||
| } | ||
| if (segments.length >= 2 && PATH_PREFIXES.includes(segments[0].toLowerCase())) { | ||
| if (VIDEO_ID.test(segments[1])) | ||
| return segments[1]; | ||
| } | ||
| } | ||
| // Last resort for input too mangled to parse as a URL at all (a lost scheme, | ||
| // a doubled query separator). Anchored on the YouTube-specific markers so a | ||
| // random 11-char word in an unrelated string can never match. | ||
| const loose = /(?:youtube(?:-nocookie)?\.com\/(?:watch\?(?:[^#]*&)?v=|shorts\/|live\/|embed\/|v\/)|youtu\.be\/)([A-Za-z0-9_-]{11})/.exec(cleaned); | ||
| return loose ? loose[1] : null; | ||
| } | ||
| /** The one URL shape every yt-dlp call and every manifest uses. */ | ||
| export function canonicalYoutubeUrl(videoId) { | ||
| return `https://www.youtube.com/watch?v=${videoId}`; | ||
| } |
@@ -43,2 +43,11 @@ // GENERATED from lib/context-core — do not edit here | ||
| } | ||
| /** | ||
| * For text going INSIDE a code span, where mdInline is exactly wrong: it strips | ||
| * `_`, `*` and `[]`, which silently corrupts an error code or a shell command | ||
| * the reader is meant to copy. A code span only has to survive its own fence, | ||
| * so backticks and newlines are the only things that need removing. | ||
| */ | ||
| function mdCode(raw) { | ||
| return raw.replace(/[\r\n]+/g, ' ').replace(/`/g, '').trim(); | ||
| } | ||
| export function buildManifest(input) { | ||
@@ -63,2 +72,3 @@ return { | ||
| ...(input.frames && input.frames.length ? { frames: input.frames } : {}), | ||
| ...(input.completeness ? { completeness: input.completeness } : {}), | ||
| createdAt: input.createdAt ?? new Date().toISOString(), | ||
@@ -77,2 +87,3 @@ }; | ||
| auto_captions: 'provider auto-generated captions', | ||
| auto_captions_translated: 'provider auto-generated captions, machine-translated from another language', | ||
| user_file: 'a caption file supplied by the user', | ||
@@ -88,2 +99,30 @@ local_stt: 'local speech-to-text on the user’s machine', | ||
| lines.push(''); | ||
| // Directly under the title, before anything a reader might act on: a bundle | ||
| // that is missing evidence must say so where nobody can miss it, and a bundle | ||
| // that is transcript-only ON PURPOSE must say THAT — otherwise the two are | ||
| // indistinguishable on disk and an agent re-runs a finished import, or trusts | ||
| // an unfinished one. | ||
| const completeness = manifest.completeness; | ||
| if (completeness && completeness.status === 'incomplete') { | ||
| lines.push('## Incomplete'); | ||
| lines.push(''); | ||
| lines.push(`**This bundle is missing visual evidence.** ${completeness.missingFrames | ||
| ? `${completeness.missingFrames} frame${completeness.missingFrames === 1 ? '' : 's'} the classifier asked for ${completeness.missingFrames === 1 ? 'was' : 'were'} not captured` | ||
| : 'Frames the classifier asked for were not captured'}${completeness.reasonCode ? ` (\`${mdCode(completeness.reasonCode)}\`)` : ''}.`); | ||
| lines.push(''); | ||
| if (completeness.reason) { | ||
| lines.push(`- Why: ${mdInline(completeness.reason)}`); | ||
| } | ||
| lines.push('- Still complete and usable: the transcript, the metadata, and the server classification below. Read them normally.'); | ||
| if (completeness.rerunCommand) { | ||
| lines.push(`- To complete this bundle, re-run exactly: \`${mdCode(completeness.rerunCommand)}\``); | ||
| lines.push(' Imports are idempotent — this fills in what is missing rather than creating a second document.'); | ||
| } | ||
| lines.push(''); | ||
| } | ||
| else if (manifest.serverClassification && | ||
| !manifest.serverClassification.needsVisual) { | ||
| lines.push('> This bundle is transcript-only BY DESIGN: the classifier judged the words sufficient on their own, so no frames were planned. Nothing is missing — do not re-run this import to "complete" it.'); | ||
| lines.push(''); | ||
| } | ||
| lines.push('## Metadata'); | ||
@@ -90,0 +129,0 @@ lines.push(''); |
+194
-58
@@ -18,19 +18,17 @@ /** | ||
| import { buildManifest, buildNormalizedTranscript, classifyTranscript, parseSrt, parseVtt, parseYoutubeJson3, renderArecMarkdown, slugHash, } from "../context-core/index.js"; | ||
| import { fetchCaptions, fetchVideoMeta, resolveYtDlp } from "./ytdlp.js"; | ||
| import { describeCaptions, fetchCaptions, fetchVideoMeta, resolveYtDlp } from "./ytdlp.js"; | ||
| import { canonicalYoutubeUrl, isYoutubeHost, parseYoutubeId } from "./youtubeUrl.js"; | ||
| import { ImportError } from "./errors.js"; | ||
| import { looksLikeStaleBinary } from "./retry.js"; | ||
| import { probeVideo } from "./probe.js"; | ||
| import { extractFrames } from "./frames.js"; | ||
| import { borrowLocalVideo, borrowYoutubeVideo } from "./videoFetch.js"; | ||
| const YOUTUBE_HOSTS = new Set([ | ||
| "youtube.com", | ||
| "www.youtube.com", | ||
| "m.youtube.com", | ||
| "music.youtube.com", | ||
| "youtu.be", | ||
| "www.youtu.be", | ||
| ]); | ||
| function classifyInput(raw) { | ||
| const trimmed = raw.trim(); | ||
| // Shell-escaped input (`watch\?v\=…`) still has to be recognised as YouTube, | ||
| // so host detection runs on the de-escaped string. | ||
| const unescaped = trimmed.replace(/\\/g, ""); | ||
| let parsed = null; | ||
| try { | ||
| parsed = new URL(trimmed); | ||
| parsed = new URL(unescaped); | ||
| } | ||
@@ -41,6 +39,11 @@ catch { | ||
| if (parsed && (parsed.protocol === "http:" || parsed.protocol === "https:")) { | ||
| const host = parsed.hostname.toLowerCase(); | ||
| if (YOUTUBE_HOSTS.has(host)) | ||
| return { kind: "youtube", url: trimmed }; | ||
| return { kind: "url", url: trimmed }; | ||
| if (isYoutubeHost(parsed.hostname)) { | ||
| const videoId = parseYoutubeId(trimmed); | ||
| if (!videoId) { | ||
| throw new ImportError("invalid_url", `could not find a video id in "${raw}". Channel, playlist and search URLs are not supported.`, `Pass a single-video URL, e.g. clipy context import "https://www.youtube.com/watch?v=<id>" (youtu.be/<id> and /shorts/<id> also work).`); | ||
| } | ||
| // Everything downstream uses the canonical URL — never the raw input. | ||
| return { kind: "youtube", url: canonicalYoutubeUrl(videoId), videoId }; | ||
| } | ||
| return { kind: "url", url: unescaped }; | ||
| } | ||
@@ -50,3 +53,3 @@ const path = resolve(trimmed); | ||
| return { kind: "local", path }; | ||
| throw new Error(`could not read "${raw}" — pass a YouTube URL or the path to a local video file.`); | ||
| throw new ImportError("source_unreadable", `could not read "${raw}" — it is neither a YouTube URL nor a readable local file.`, `Check the path exists, or pass a YouTube URL: clipy context import "https://www.youtube.com/watch?v=<id>"`); | ||
| } | ||
@@ -68,3 +71,3 @@ function sha256File(path) { | ||
| catch { | ||
| throw new Error(`--transcript ${path} is neither .vtt/.srt nor valid JSON. Supported: WebVTT, SubRip, or Clipy transcript JSON ({"segments":[{"startMs","endMs","text"}]}).`); | ||
| throw new ImportError("transcript_unreadable", `--transcript ${path} is neither .vtt/.srt nor valid JSON.`, `Supply WebVTT (.vtt), SubRip (.srt), or Clipy transcript JSON ({"segments":[{"startMs","endMs","text"}]}).`); | ||
| } | ||
@@ -77,3 +80,3 @@ // json3 (YouTube) and Clipy transcript JSON both land here. | ||
| if (!Array.isArray(raw)) { | ||
| throw new Error(`--transcript ${path} has no usable segments.`); | ||
| throw new ImportError("transcript_unreadable", `--transcript ${path} has no usable segments.`, `Supply WebVTT (.vtt), SubRip (.srt), or Clipy transcript JSON ({"segments":[{"startMs","endMs","text"}]}).`); | ||
| } | ||
@@ -91,6 +94,22 @@ const segments = []; | ||
| } | ||
| if (segments.length === 0) | ||
| throw new Error(`--transcript ${path} has no usable segments.`); | ||
| if (segments.length === 0) { | ||
| throw new ImportError("transcript_unreadable", `--transcript ${path} has no usable segments.`, `Supply WebVTT (.vtt), SubRip (.srt), or Clipy transcript JSON ({"segments":[{"startMs","endMs","text"}]}).`); | ||
| } | ||
| return segments; | ||
| } | ||
| /** Platform-correct, runnable verbatim — an agent should be able to paste it. */ | ||
| const FFMPEG_INSTALL_COMMAND = process.platform === "darwin" | ||
| ? "brew install ffmpeg" | ||
| : process.platform === "win32" | ||
| ? "winget install Gyan.FFmpeg" | ||
| : "sudo apt install ffmpeg"; | ||
| /** Resolves + parses `--transcript`, shared by every input kind. */ | ||
| function loadUserTranscript(transcriptPath) { | ||
| const resolved = resolve(transcriptPath); | ||
| if (!existsSync(resolved)) { | ||
| throw new ImportError("transcript_unreadable", `--transcript ${resolved} does not exist.`, `Check the path, or drop --transcript to use the provider's captions.`); | ||
| } | ||
| notify(`Reading the transcript from ${basename(resolved)}…`); | ||
| return parseTranscriptFile(resolved); | ||
| } | ||
| function notify(message) { | ||
@@ -128,18 +147,63 @@ process.stderr.write(`${message}\n`); | ||
| } | ||
| async function compileYoutube(url, opts) { | ||
| async function compileYoutube(url, videoId, opts) { | ||
| const bin = await resolveYtDlp(notify); | ||
| notify("Fetching video info…"); | ||
| const meta = await fetchVideoMeta(bin, url); | ||
| // A zero duration is how YouTube's placeholder/consent stubs come back. They | ||
| // are not real videos, and compiling one produces an empty bundle attributed | ||
| // to a URL the user did ask for — worse than a refusal. | ||
| if (!meta.durationMs) { | ||
| throw new ImportError("invalid_url", `could not read this video: ${url} returned no duration. That usually means it is private, region-blocked, age-gated, members-only, or an unfinished live stream.`, `Open the URL in a browser to check it plays, or import a local copy: clipy context import ./<file> --transcript <file.vtt>`); | ||
| } | ||
| notify(`Found: "${meta.title}" (${fmtClock(meta.durationMs)})`); | ||
| const langPref = opts.language ?? meta.language ?? "en"; | ||
| notify(`Downloading captions (${langPref})…`); | ||
| const captions = await fetchCaptions(bin, url, meta, langPref); | ||
| // providerId comes from the URL we parsed, not from yt-dlp's echo, so the | ||
| // manifest points at the video the user named even if metadata is partial. | ||
| const id = meta.id || videoId; | ||
| const source = { | ||
| kind: "youtube", | ||
| canonicalUrl: canonicalYoutubeUrl(id), | ||
| providerId: id, | ||
| }; | ||
| // An explicit --transcript is the user overriding the provider, so YouTube's | ||
| // captions are never consulted — not even as a fallback. | ||
| if (opts.transcriptPath) { | ||
| const segments = loadUserTranscript(opts.transcriptPath); | ||
| notify(`Using your transcript file (${segments.length} segments) — skipping YouTube captions.`); | ||
| const transcript = buildNormalizedTranscript(segments, { | ||
| ...(opts.language ? { language: opts.language } : {}), | ||
| source: "user_file", | ||
| durationMs: meta.durationMs || undefined, | ||
| }); | ||
| return finish({ | ||
| title: opts.title ?? meta.title, | ||
| source, | ||
| durationMs: meta.durationMs, | ||
| transcript, | ||
| fingerprint: `youtube:${id}`, | ||
| media: { kind: "youtube", url, ytDlpBin: bin, durationMs: meta.durationMs }, | ||
| opts, | ||
| }); | ||
| } | ||
| let downloadFailure = null; | ||
| const captions = await fetchCaptions(bin, url, meta, { | ||
| ...(opts.language ? { language: opts.language } : {}), | ||
| notify, | ||
| onFailure: (reason) => { | ||
| downloadFailure = reason; | ||
| }, | ||
| }); | ||
| if (!captions) { | ||
| // A listed track that refused to download is a different problem with a | ||
| // different fix, so it must not be reported as an absent track. | ||
| if (downloadFailure) { | ||
| const stale = looksLikeStaleBinary(downloadFailure); | ||
| throw new ImportError("no_captions", `the captions are listed but could not be downloaded: ${downloadFailure}`, stale | ||
| ? `This looks like an out-of-date yt-dlp (an extraction failure, not a block). Update it — the managed copy self-updates, or run: yt-dlp -U` | ||
| : `If this is HTTP 429, YouTube is rate-limiting this machine — wait a few minutes and re-run. Otherwise supply your own captions: clipy context import <url> --transcript <file.vtt>`); | ||
| } | ||
| const available = [...new Set([...meta.subtitleLangs, ...meta.autoCaptionLangs])]; | ||
| if (available.length > 0) { | ||
| throw new Error(`this video has no captions in "${langPref}". Re-run with --language <code> (e.g. --language ${available.includes("en") ? "en" : available[0]}).`); | ||
| throw new ImportError("no_captions", `this video has no captions in "${opts.language ?? "any usable language"}". Available: ${available.slice(0, 20).join(", ")}${available.length > 20 ? `, … (${available.length} total)` : ""}.`, `Re-run picking one of those: clipy context import <url> --language ${available[0]}`); | ||
| } | ||
| throw new Error("this video has no captions (neither creator-provided nor auto-generated).\n" + | ||
| " Supply your own with --transcript <file.vtt|file.srt|transcript.json>.\n" + | ||
| " Local speech-to-text for caption-less videos lands in a future release."); | ||
| throw new ImportError("no_captions", "this video has no captions at all (neither creator-provided nor auto-generated).", `Supply your own: clipy context import <url> --transcript <file.vtt|file.srt|transcript.json>`); | ||
| } | ||
@@ -154,7 +218,3 @@ const raw = captions.format === "json3" | ||
| }); | ||
| notify(`Got ${transcript.segments.length} caption segments (${captions.source === "creator_captions" ? "creator-provided" : "auto-generated"}, ${captions.language}).`); | ||
| const source = { | ||
| kind: "youtube", | ||
| ...(meta.id ? { canonicalUrl: `https://www.youtube.com/watch?v=${meta.id}`, providerId: meta.id } : {}), | ||
| }; | ||
| notify(`Got ${transcript.segments.length} caption segments (${describeCaptions(captions)}).`); | ||
| return finish({ | ||
@@ -165,3 +225,3 @@ title: opts.title ?? meta.title, | ||
| transcript, | ||
| fingerprint: `youtube:${meta.id || url}`, | ||
| fingerprint: `youtube:${id}`, | ||
| media: { kind: "youtube", url, ytDlpBin: bin, durationMs: meta.durationMs }, | ||
@@ -173,13 +233,21 @@ opts, | ||
| if (!opts.transcriptPath) { | ||
| throw new Error(`local files need a transcript: pass --transcript <file.vtt|file.srt|transcript.json>.\n` + | ||
| " Local speech-to-text lands in a future release."); | ||
| throw new ImportError("no_captions", "local files need a transcript — Clipy cannot transcribe them yet.", `Re-run with a caption file: clipy context import ${JSON.stringify(path)} --transcript <file.vtt|file.srt|transcript.json>`); | ||
| } | ||
| const transcriptPath = resolve(opts.transcriptPath); | ||
| if (!existsSync(transcriptPath)) | ||
| throw new Error(`--transcript ${transcriptPath} does not exist.`); | ||
| notify(`Probing ${basename(path)} with ffprobe…`); | ||
| const probe = await probeVideo(path); | ||
| let probe; | ||
| try { | ||
| probe = await probeVideo(path); | ||
| } | ||
| catch (e) { | ||
| const message = e.message; | ||
| if (/ffprobe was not found/i.test(message)) { | ||
| throw new ImportError("ffmpeg_missing", message, FFMPEG_INSTALL_COMMAND); | ||
| } | ||
| if (/no video stream/i.test(message)) { | ||
| throw new ImportError("no_video_stream", message, `Supply a file with a video track, or import the audio transcript-only with --no-frames.`); | ||
| } | ||
| throw new ImportError("source_unreadable", message, `Check the file plays, then re-run.`); | ||
| } | ||
| const contentHash = sha256File(path); | ||
| notify(`Reading the transcript from ${basename(transcriptPath)}…`); | ||
| const segments = parseTranscriptFile(transcriptPath); | ||
| const segments = loadUserTranscript(opts.transcriptPath); | ||
| notify(`Got ${segments.length} transcript segments (${fmtClock(probe.durationMs)} of video).`); | ||
@@ -228,3 +296,3 @@ const transcript = buildNormalizedTranscript(segments, { | ||
| */ | ||
| function withVisualEvidence(compiled, classification, frames) { | ||
| function withVisualEvidence(compiled, classification, frames, completeness) { | ||
| const manifest = { | ||
@@ -235,2 +303,3 @@ ...compiled.manifest, | ||
| ...(frames.length > 0 ? { frames } : {}), | ||
| ...(completeness ? { completeness } : {}), | ||
| }; | ||
@@ -336,2 +405,5 @@ return { | ||
| } | ||
| catch (e) { | ||
| throw new ImportError("server_unreachable", `could not reach the Clipy API at ${opts.apiUrl}: ${e.message}`, `Check your network, then re-run. Without --sync the local bundle is still written and readable with: clipy context read <bundle>`); | ||
| } | ||
| finally { | ||
@@ -393,4 +465,3 @@ clearTimeout(timer); | ||
| if (!opts.apiKey) { | ||
| throw new Error(`--sync needs an API key. Run \`clipy login\` (or set CLIPY_API_KEY), then re-run.\n` + | ||
| ` Nothing was lost: the local bundle is already written to ${bundlePath}.`); | ||
| throw new ImportError("auth_required", `--sync needs an API key, and none is configured. The local bundle is already written to ${bundlePath}.`, "clipy login", { bundlePath }); | ||
| } | ||
@@ -412,17 +483,16 @@ const payload = { | ||
| const detail = typeof body.error === "string" ? body.error : `HTTP ${status}`; | ||
| const intact = `Your local bundle is complete and unaffected: ${bundlePath}`; | ||
| const partial = { bundlePath }; | ||
| if (status === 401) { | ||
| throw new Error(`sync failed while uploading the transcript: ${detail}\n` + | ||
| ` Run \`clipy login\` to set a new key, then re-run.\n ${intact}`); | ||
| throw new ImportError("auth_required", `sync failed: ${detail}. Your local bundle is complete and unaffected: ${bundlePath}`, "clipy login", partial); | ||
| } | ||
| if (status === 403) { | ||
| throw new ImportError("wrong_scope", `sync failed: ${detail}. The key is valid but lacks the ingest scope. Your local bundle is complete and unaffected: ${bundlePath}`, "Mint a key with the ingest scope at https://clipy.online/settings/api-keys, then re-run with it.", partial); | ||
| } | ||
| if (status === 409) { | ||
| throw new Error(`sync failed while uploading the transcript: ${detail}\n` + | ||
| ` That idempotency key already points at different content — this source changed since the last import.\n` + | ||
| ` Delete the old document in your library, or import from a fresh copy.\n ${intact}`); | ||
| throw new ImportError("content_conflict", `sync failed: ${detail}. That idempotency key already points at different content — this source changed since the last import. Your local bundle is complete and unaffected: ${bundlePath}`, "Delete the old document in your library, or import from a fresh copy.", partial); | ||
| } | ||
| if (status === 429) { | ||
| throw new Error(`sync failed while uploading the transcript: ${detail}\n` + | ||
| ` You have hit today's ingest quota (it resets at 00:00 UTC). Re-run then.\n ${intact}`); | ||
| throw new ImportError("quota_exceeded", `sync failed: ${detail}. You have hit today's ingest quota (it resets at 00:00 UTC). Your local bundle is complete and unaffected: ${bundlePath}`, "Re-run after 00:00 UTC. Do not retry in a loop — it only burns the limit.", partial); | ||
| } | ||
| throw new Error(`sync failed while uploading the transcript: ${detail}\n ${intact}`); | ||
| throw new ImportError("server_unreachable", `sync failed while uploading the transcript: ${detail}. Your local bundle is complete and unaffected: ${bundlePath}`, `Re-run the same command. The bundle is readable meanwhile: clipy context read ${bundlePath}`, partial); | ||
| } | ||
@@ -501,5 +571,5 @@ return { | ||
| if (input.kind === "url") { | ||
| throw new Error("direct media URLs are not supported yet — pass a YouTube URL or download the file first and import it locally."); | ||
| throw new ImportError("invalid_url", "direct media URLs are not supported yet.", `Download the file first, then: clipy context import ./<file> --transcript <file.vtt>`); | ||
| } | ||
| let compiled = input.kind === "youtube" ? await compileYoutube(input.url, opts) : await compileLocal(input.path, opts); | ||
| let compiled = input.kind === "youtube" ? await compileYoutube(input.url, input.videoId, opts) : await compileLocal(input.path, opts); | ||
| const outputDir = resolve(opts.outputDir ?? process.cwd()); | ||
@@ -511,2 +581,28 @@ let { path: bundlePath, rewritten } = writeBundle(outputDir, compiled); | ||
| let frameUpload = null; | ||
| // Everything that went wrong WITHOUT costing the user the import. These ride | ||
| // out on a successful (exit 0) result — an agent must not read a missing | ||
| // frame as a failed import. | ||
| const warnings = []; | ||
| /** | ||
| * The bundle outlives the run, and the warning envelope does not — so the | ||
| * verdict has to be written INTO the document. "Transcript-only because the | ||
| * words were enough" and "transcript-only because the frames failed" look | ||
| * identical on disk otherwise. | ||
| */ | ||
| const completenessOf = (classification, | ||
| /** Frames that reached the DOCUMENT — not merely the local bundle. A failed | ||
| * upload leaves the images on disk but the document still can't show them. */ | ||
| attachedFrames = 0) => { | ||
| const planned = classification.needsVisual ? classification.frameTimestampsMs.length : 0; | ||
| const missing = Math.max(0, planned - attachedFrames); | ||
| if (missing === 0) | ||
| return { status: "complete" }; | ||
| const cause = warnings[0]; | ||
| return { | ||
| status: "incomplete", | ||
| missingFrames: missing, | ||
| ...(cause ? { reasonCode: cause.code, reason: cause.error } : {}), | ||
| rerunCommand: rerunCommand(target, opts), | ||
| }; | ||
| }; | ||
| if (opts.sync) { | ||
@@ -538,2 +634,13 @@ // Phase 1: the transcript bundle goes up, the verdict comes back. | ||
| if (failure) { | ||
| warnings.push({ | ||
| code: /403/.test(failure) | ||
| ? "ytdlp_download_403" | ||
| : /ffmpeg|ffprobe/i.test(failure) | ||
| ? "ffmpeg_missing" | ||
| : "frames_upload_failed", | ||
| error: `frames could not be extracted: ${failure.split("\n")[0]}`, | ||
| remediation: /ffmpeg|ffprobe/i.test(failure) | ||
| ? FFMPEG_INSTALL_COMMAND | ||
| : rerunCommand(target, opts), | ||
| }); | ||
| // Phase 1 already succeeded — this is a PARTIAL success, and saying | ||
@@ -557,2 +664,7 @@ // "failed" here would send the user hunting for a document that is | ||
| catch (e) { | ||
| warnings.push({ | ||
| code: "frames_upload_failed", | ||
| error: `the frames were cut but ${e.message}`, | ||
| remediation: rerunCommand(target, opts), | ||
| }); | ||
| // Same partial-success rule as a failed extraction: the document | ||
@@ -568,2 +680,7 @@ // exists, only the pictures are missing. | ||
| else if (frames.length > 0) { | ||
| warnings.push({ | ||
| code: "frames_upload_failed", | ||
| error: "frames were extracted but the server returned no document id, so they were not uploaded.", | ||
| remediation: rerunCommand(target, opts), | ||
| }); | ||
| notify("frames were extracted but the server returned no document id, so they were not uploaded."); | ||
@@ -582,3 +699,5 @@ } | ||
| }; | ||
| })); | ||
| }), | ||
| // frameUpload is set only when the server accepted them. | ||
| completenessOf(classification, frameUpload ? uploadedFrames.length : 0)); | ||
| ({ path: bundlePath } = writeBundle(outputDir, compiled, uploadedFrames)); | ||
@@ -593,3 +712,3 @@ } | ||
| if (uploadedFrames.length === 0) { | ||
| compiled = withVisualEvidence(compiled, classification, []); | ||
| compiled = withVisualEvidence(compiled, classification, [], completenessOf(classification)); | ||
| ({ path: bundlePath } = writeBundle(outputDir, compiled)); | ||
@@ -601,3 +720,8 @@ } | ||
| process.stdout.write(`${JSON.stringify({ | ||
| ok: true, | ||
| bundlePath, | ||
| // The file an agent should actually open. Naming the directory alone | ||
| // makes every caller guess at the entry point. | ||
| contextPath: join(bundlePath, "recording.md"), | ||
| title: compiled.manifest.title, | ||
| profile: compiled.manifest.profile, | ||
@@ -608,8 +732,21 @@ recommendedProfile: report?.recommendedProfile ?? null, | ||
| ...(synced ? { synced: true, publicId: synced.publicId } : opts.sync ? { synced: false } : {}), | ||
| ...(synced?.folderName ? { folderName: synced.folderName } : {}), | ||
| classification: synced?.classification ?? null, | ||
| frames: uploadedFrames.length, | ||
| ...(frameUpload ? { framesAdded: frameUpload.added, framesTotal: frameUpload.total } : {}), | ||
| warnings, | ||
| }, null, 2)}\n`); | ||
| return; | ||
| } | ||
| // The headline. A user who ran one command should not have to assemble "did | ||
| // it work, and where is it" out of four key-value lines. | ||
| process.stdout.write(`\nYour agent-ready context for ${JSON.stringify(compiled.manifest.title)} is ready.\n`); | ||
| process.stdout.write(` → local bundle: ${join(bundlePath, "recording.md")} (point your agent here, or run: clipy context read ${bundlePath})\n`); | ||
| if (synced) { | ||
| process.stdout.write(` → in your Clipy library: filed under ${JSON.stringify(synced.folderName ?? "Knowledge Base")} — searchable, and readable by agents via MCP (read_context_document ${synced.publicId})\n`); | ||
| } | ||
| if (warnings.length > 0) { | ||
| process.stdout.write(` → incomplete: ${warnings.map((w) => w.error).join("; ")}\n to finish it: ${warnings[0].remediation}\n`); | ||
| } | ||
| process.stdout.write("\n"); | ||
| process.stdout.write(`bundle: ${bundlePath}${rewritten ? " (rewritten — source content changed)" : ""}\n`); | ||
@@ -632,3 +769,2 @@ if (synced?.classification) { | ||
| } | ||
| process.stdout.write(`next: clipy context read ${bundlePath}\n`); | ||
| } |
+134
-58
@@ -14,2 +14,3 @@ /** | ||
| import { join } from "node:path"; | ||
| import { BASELINE_DOWNLOAD_ARGS, DEFAULT_FORMAT, MAX_ATTEMPTS, PROGRESSIVE_FALLBACK, RETRY_BACKOFF_MS, TOKEN_FREE_CLIENT_ARGS, classifyFailure, isRetriable, retryNotice, sleep, } from "./retry.js"; | ||
| /** A 720p video-only rendition of a long talk still runs to hundreds of MB; | ||
@@ -69,3 +70,135 @@ * past this we are no longer "borrowing" the file. */ | ||
| } | ||
| /** Leftovers from a failed attempt would be mistaken for a finished download. */ | ||
| function clearDir(dir) { | ||
| for (const entry of readdirSync(dir)) | ||
| rmSync(join(dir, entry), { recursive: true, force: true }); | ||
| } | ||
| function attemptDownload(dir, input, plan) { | ||
| return new Promise((resolveRun, reject) => { | ||
| const child = spawn(input.ytDlpBin, [ | ||
| "--ignore-config", | ||
| "--no-playlist", | ||
| "--no-part", | ||
| "--no-progress", | ||
| ...BASELINE_DOWNLOAD_ARGS, | ||
| ...plan.extraArgs, | ||
| "--max-filesize", | ||
| `${Math.floor(MAX_BYTES / 1_000_000)}M`, | ||
| "-f", | ||
| plan.format, | ||
| "-o", | ||
| join(dir, "source.%(ext)s"), | ||
| input.url, | ||
| ], { stdio: ["ignore", "ignore", "pipe"] }); | ||
| let stderr = ""; | ||
| let aborted = null; | ||
| child.stderr.on("data", (d) => { | ||
| if (stderr.length < 16_384) | ||
| stderr += d.toString("utf8"); | ||
| }); | ||
| // --max-filesize only knows sizes the server declared up front, so watch | ||
| // the bytes actually landing too. | ||
| const poll = setInterval(() => { | ||
| try { | ||
| if (dirBytes(dir) > MAX_BYTES) { | ||
| aborted = `the download passed the ${Math.round(MAX_BYTES / 1_000_000_000)}GB ceiling`; | ||
| child.kill("SIGKILL"); | ||
| } | ||
| } | ||
| catch { | ||
| // Directory already gone; the close handler will sort it out. | ||
| } | ||
| }, SIZE_POLL_MS); | ||
| const timer = setTimeout(() => { | ||
| aborted = "the download timed out"; | ||
| child.kill("SIGKILL"); | ||
| }, DOWNLOAD_TIMEOUT_MS); | ||
| const done = () => { | ||
| clearInterval(poll); | ||
| clearTimeout(timer); | ||
| }; | ||
| child.on("error", (e) => { | ||
| done(); | ||
| reject(e); | ||
| }); | ||
| child.on("close", (code) => { | ||
| done(); | ||
| if (aborted) { | ||
| resolveRun({ ok: false, abort: aborted }); | ||
| return; | ||
| } | ||
| if (code !== 0) { | ||
| resolveRun({ ok: false, stderr: stderr.trim() || `exit ${code}` }); | ||
| return; | ||
| } | ||
| resolveRun({ ok: true }); | ||
| }); | ||
| }); | ||
| } | ||
| /** | ||
| * Three attempts on yt-dlp's own defaults, then two escalations. | ||
| * | ||
| * The 403 this exists for is not a verdict about the video — the identical | ||
| * command succeeds a minute later, because the media URL YouTube signed has | ||
| * expired and only a fresh extraction mints a new one. Re-running the binary IS | ||
| * the re-extraction, which is why the outer loop earns its keep even though | ||
| * BASELINE_DOWNLOAD_ARGS already makes yt-dlp retry internally. | ||
| * | ||
| * The escalation ladder and the reasoning behind each rung are documented in | ||
| * docs/research/2026-07-29-ytdlp-403-fallbacks.md §3. | ||
| */ | ||
| async function downloadWithRetries(dir, input) { | ||
| const base = { format: DEFAULT_FORMAT, extraArgs: [] }; | ||
| let last = ""; | ||
| const escalations = [ | ||
| { | ||
| plan: { format: DEFAULT_FORMAT, extraArgs: TOKEN_FREE_CLIENT_ARGS }, | ||
| notice: "Still blocked — trying YouTube's token-free player clients (tv, android_vr, web_embedded)…", | ||
| success: "A different player client worked — continuing with frame extraction.", | ||
| }, | ||
| { | ||
| plan: PROGRESSIVE_FALLBACK, | ||
| notice: "Still blocked — falling back to a pre-muxed progressive format (lower resolution, but it downloads in one piece)…", | ||
| success: "The progressive format worked — continuing with frame extraction.", | ||
| }, | ||
| ]; | ||
| for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { | ||
| if (attempt > 1) | ||
| clearDir(dir); | ||
| const result = await attemptDownload(dir, input, base); | ||
| if (result.ok) | ||
| return; | ||
| if ("abort" in result) | ||
| throw new VideoUnavailableError(`${result.abort}, so frames were skipped.`); | ||
| last = result.stderr; | ||
| const kind = classifyFailure(last); | ||
| if (!isRetriable(kind)) { | ||
| throw new VideoUnavailableError(`yt-dlp could not download the video for frame extraction: ${lastLine(last)}`); | ||
| } | ||
| if (attempt === MAX_ATTEMPTS) | ||
| break; | ||
| input.notify(retryNotice(last, kind, attempt + 1, MAX_ATTEMPTS)); | ||
| await sleep(RETRY_BACKOFF_MS[attempt - 1] ?? 5_000); | ||
| } | ||
| for (const step of escalations) { | ||
| input.notify(step.notice); | ||
| clearDir(dir); | ||
| const result = await attemptDownload(dir, input, step.plan); | ||
| if (result.ok) { | ||
| input.notify(step.success); | ||
| return; | ||
| } | ||
| if ("abort" in result) | ||
| throw new VideoUnavailableError(`${result.abort}, so frames were skipped.`); | ||
| last = result.stderr; | ||
| // A fatal answer from a different client is the video's real answer. | ||
| if (!isRetriable(classifyFailure(last))) | ||
| break; | ||
| } | ||
| throw new VideoUnavailableError(`yt-dlp could not download the video for frame extraction: ${lastLine(last)}`); | ||
| } | ||
| function lastLine(stderr) { | ||
| return stderr.split("\n").filter(Boolean).pop() ?? "unknown error"; | ||
| } | ||
| /** | ||
| * Downloads a bounded rendition of a YouTube video into a temp directory. | ||
@@ -88,60 +221,3 @@ * Throws VideoUnavailableError when the source is out of bounds — callers treat | ||
| input.notify("Downloading a low-resolution (≤720p) copy of the video for frame extraction — it is deleted the moment the frames are cut…"); | ||
| await new Promise((resolveRun, reject) => { | ||
| const child = spawn(input.ytDlpBin, [ | ||
| "--ignore-config", | ||
| "--no-playlist", | ||
| "--no-part", | ||
| "--no-progress", | ||
| "--max-filesize", | ||
| `${Math.floor(MAX_BYTES / 1_000_000)}M`, | ||
| "-f", | ||
| "bv*[height<=720]/b[height<=720]", | ||
| "-o", | ||
| join(dir, "source.%(ext)s"), | ||
| input.url, | ||
| ], { stdio: ["ignore", "ignore", "pipe"] }); | ||
| let stderr = ""; | ||
| let aborted = null; | ||
| child.stderr.on("data", (d) => { | ||
| if (stderr.length < 16_384) | ||
| stderr += d.toString("utf8"); | ||
| }); | ||
| // --max-filesize only knows sizes the server declared up front, so watch | ||
| // the bytes actually landing too. | ||
| const poll = setInterval(() => { | ||
| try { | ||
| if (dirBytes(dir) > MAX_BYTES) { | ||
| aborted = `the download passed the ${Math.round(MAX_BYTES / 1_000_000_000)}GB ceiling`; | ||
| child.kill("SIGKILL"); | ||
| } | ||
| } | ||
| catch { | ||
| // Directory already gone; the close handler will sort it out. | ||
| } | ||
| }, SIZE_POLL_MS); | ||
| const timer = setTimeout(() => { | ||
| aborted = "the download timed out"; | ||
| child.kill("SIGKILL"); | ||
| }, DOWNLOAD_TIMEOUT_MS); | ||
| const done = () => { | ||
| clearInterval(poll); | ||
| clearTimeout(timer); | ||
| }; | ||
| child.on("error", (e) => { | ||
| done(); | ||
| reject(e); | ||
| }); | ||
| child.on("close", (code) => { | ||
| done(); | ||
| if (aborted) { | ||
| reject(new VideoUnavailableError(`${aborted}, so frames were skipped.`)); | ||
| return; | ||
| } | ||
| if (code !== 0) { | ||
| reject(new VideoUnavailableError(`yt-dlp could not download the video for frame extraction: ${stderr.trim().split("\n").pop() ?? `exit ${code}`}`)); | ||
| return; | ||
| } | ||
| resolveRun(); | ||
| }); | ||
| }); | ||
| await downloadWithRetries(dir, input); | ||
| const file = readdirSync(dir).find((f) => f.startsWith("source.")); | ||
@@ -148,0 +224,0 @@ if (!file || !existsSync(join(dir, file)) || statSync(join(dir, file)).size === 0) { |
+185
-38
@@ -10,9 +10,19 @@ /** | ||
| import { spawn } from "node:child_process"; | ||
| import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync } from "node:fs"; | ||
| import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, statSync, utimesSync } from "node:fs"; | ||
| import { homedir, tmpdir } from "node:os"; | ||
| import { join } from "node:path"; | ||
| import { Readable } from "node:stream"; | ||
| import { MAX_ATTEMPTS, RETRY_BACKOFF_MS, classifyFailure, isRetriable, retryNotice, sleep } from "./retry.js"; | ||
| import { pipeline } from "node:stream/promises"; | ||
| import { createWriteStream } from "node:fs"; | ||
| const RELEASE_BASE = "https://github.com/yt-dlp/yt-dlp/releases/latest/download"; | ||
| /** | ||
| * NIGHTLY, not stable. yt-dlp's own README calls stable "often stale and prone | ||
| * to external breakage" and names nightly "the recommended channel for regular | ||
| * users" — and YouTube-side breakage in 2026 has repeatedly been fixed the same | ||
| * week it appeared. A stable pin means shipping known-broken extraction for | ||
| * weeks. See docs/research/2026-07-29-ytdlp-403-fallbacks.md §4. | ||
| */ | ||
| const RELEASE_BASE = "https://github.com/yt-dlp/yt-dlp-nightly-builds/releases/latest/download"; | ||
| /** Past this, our managed copy is refreshed before it is used. */ | ||
| const MAX_BINARY_AGE_MS = 7 * 24 * 60 * 60 * 1000; | ||
| const STDERR_CAP = 16_384; | ||
@@ -120,2 +130,42 @@ export function clipyBinDir() { | ||
| let cached = null; | ||
| /** | ||
| * Refreshes OUR copy if it has gone stale. | ||
| * | ||
| * Deliberately never touches a yt-dlp the user installed themselves: `-U` on a | ||
| * Homebrew or pipx binary either fails or fights the package manager, and | ||
| * either way it is not ours to modify. Failure here is always swallowed — a | ||
| * stale binary might still work, and an unreachable GitHub must not block an | ||
| * import that would otherwise succeed. | ||
| */ | ||
| async function refreshManagedBinary(bin, notify) { | ||
| if (bin !== managedPath()) | ||
| return; | ||
| let age; | ||
| try { | ||
| age = Date.now() - statSync(bin).mtimeMs; | ||
| } | ||
| catch { | ||
| return; | ||
| } | ||
| if (age < MAX_BINARY_AGE_MS) | ||
| return; | ||
| notify(`Your yt-dlp copy is ${Math.floor(age / (24 * 60 * 60 * 1000))} days old — updating it (YouTube breaks extraction often; this is Clipy's own copy, not a system install)…`); | ||
| try { | ||
| const res = await run(bin, ["-U"], 120_000); | ||
| notify(res.code === 0 | ||
| ? "yt-dlp is up to date." | ||
| : "yt-dlp could not update itself — continuing with the copy you have."); | ||
| } | ||
| catch { | ||
| notify("yt-dlp could not update itself — continuing with the copy you have."); | ||
| } | ||
| // Stamp it either way: a failed check should not be retried on every import. | ||
| try { | ||
| const now = new Date(); | ||
| utimesSync(bin, now, now); | ||
| } | ||
| catch { | ||
| // Best effort. | ||
| } | ||
| } | ||
| export async function resolveYtDlp(notify) { | ||
@@ -125,4 +175,6 @@ if (cached) | ||
| const managed = managedPath(); | ||
| if (existsSync(managed)) | ||
| if (existsSync(managed)) { | ||
| await refreshManagedBinary(managed, notify); | ||
| return (cached = managed); | ||
| } | ||
| const found = onPath(); | ||
@@ -165,3 +217,17 @@ if (found) | ||
| } | ||
| /** yt-dlp marks a video's own caption track with an -orig suffix. */ | ||
| function baseLang(track) { | ||
| return track.replace(/-orig$/i, ""); | ||
| } | ||
| /** | ||
| * The language the video was actually spoken in, if we can tell. The -orig | ||
| * track name is the strongest signal (YouTube adds it precisely to distinguish | ||
| * the source track from its ~200 auto-translations); the metadata `language` | ||
| * field is the fallback. | ||
| */ | ||
| function originalLanguage(meta) { | ||
| const tagged = [...meta.subtitleLangs, ...meta.autoCaptionLangs].find((l) => /-orig$/i.test(l)); | ||
| return tagged ?? meta.language; | ||
| } | ||
| /** | ||
| * Picks one caption track and downloads only that track. Creator captions beat | ||
@@ -182,43 +248,124 @@ * auto-captions; an exact language match beats a regional variant (en-US for | ||
| } | ||
| export async function fetchCaptions(bin, url, meta, langPref) { | ||
| /** | ||
| * Which caption track to try, in order. Exported for tests: the ranking is the | ||
| * whole feature, and it is worth asserting without a network round trip. | ||
| * | ||
| * An explicit --language is obeyed and nothing else is attempted — silently | ||
| * importing a different language than the one asked for would be worse than | ||
| * failing. Otherwise: creator captions beat machine ones, and within the | ||
| * machine ones the video's ORIGINAL language beats a translation of it, | ||
| * because YouTube's auto-translations are a translation OF a transcription and | ||
| * degrade twice over. | ||
| */ | ||
| export function planCaptionAttempts(meta, explicitLanguage) { | ||
| const attempts = []; | ||
| const creator = pickLang(meta.subtitleLangs, langPref); | ||
| if (creator) | ||
| attempts.push({ source: "creator_captions", lang: creator, flag: "--write-subs" }); | ||
| const auto = pickLang(meta.autoCaptionLangs, langPref); | ||
| if (auto) | ||
| attempts.push({ source: "auto_captions", lang: auto, flag: "--write-auto-subs" }); | ||
| const seen = new Set(); | ||
| const orig = originalLanguage(meta); | ||
| const add = (track, kind) => { | ||
| if (!track || seen.has(`${kind}:${track}`)) | ||
| return; | ||
| seen.add(`${kind}:${track}`); | ||
| const language = baseLang(track); | ||
| const translated = kind === "auto" && !!orig && baseLang(orig).toLowerCase() !== language.toLowerCase(); | ||
| attempts.push({ | ||
| track, | ||
| language, | ||
| source: kind === "creator" | ||
| ? "creator_captions" | ||
| : translated | ||
| ? "auto_captions_translated" | ||
| : "auto_captions", | ||
| ...(translated ? { translatedFrom: baseLang(orig) } : {}), | ||
| flag: kind === "creator" ? "--write-subs" : "--write-auto-subs", | ||
| }); | ||
| }; | ||
| if (explicitLanguage) { | ||
| add(pickLang(meta.subtitleLangs, explicitLanguage), "creator"); | ||
| add(pickLang(meta.autoCaptionLangs, explicitLanguage), "auto"); | ||
| return attempts; | ||
| } | ||
| // 2. Any creator track, the original language for preference. | ||
| add(orig ? pickLang(meta.subtitleLangs, orig) : null, "creator"); | ||
| add(meta.subtitleLangs[0], "creator"); | ||
| // 3-5. Auto: original language, then English, then whatever exists. | ||
| add(orig ? pickLang(meta.autoCaptionLangs, orig) : null, "auto"); | ||
| add(pickLang(meta.autoCaptionLangs, "en"), "auto"); | ||
| add(meta.autoCaptionLangs[0], "auto"); | ||
| return attempts; | ||
| } | ||
| /** One human-readable phrase for a track's provenance. */ | ||
| export function describeCaptions(c) { | ||
| if (c.source === "creator_captions") | ||
| return `${c.language}, creator-provided`; | ||
| if (c.source === "auto_captions_translated") { | ||
| return `${c.language}, auto-translated from ${c.translatedFrom}`; | ||
| } | ||
| return `${c.language}, auto-generated — video's original language`; | ||
| } | ||
| export async function fetchCaptions(bin, url, meta, opts = {}) { | ||
| const attempts = planCaptionAttempts(meta, opts.language); | ||
| // A track can be listed and still refuse to download — YouTube rate-limits | ||
| // (HTTP 429) under exactly the retry patterns an import produces. Reporting | ||
| // that as "this video has no captions" sends the user to fix the wrong thing. | ||
| let lastFailure = null; | ||
| for (const attempt of attempts) { | ||
| for (const format of ["json3", "vtt"]) { | ||
| const dir = mkdtempSync(join(tmpdir(), "clipy-subs-")); | ||
| try { | ||
| const res = await run(bin, [ | ||
| "--ignore-config", | ||
| "--no-playlist", | ||
| "--skip-download", | ||
| attempt.flag, | ||
| "--sub-langs", | ||
| attempt.lang, | ||
| "--sub-format", | ||
| format, | ||
| "-o", | ||
| join(dir, "track.%(ext)s"), | ||
| url, | ||
| ], 180_000); | ||
| if (res.code !== 0) | ||
| continue; | ||
| const file = readdirSync(dir).find((f) => f.endsWith(`.${format}`)); | ||
| if (!file) | ||
| continue; | ||
| const text = readFileSync(join(dir, file), "utf8"); | ||
| if (!text.trim()) | ||
| continue; | ||
| return { format, text, source: attempt.source, language: attempt.lang }; | ||
| opts.notify?.(`Downloading captions (${describeCaptions(attempt)})…`); | ||
| // Retrying the PREFERRED track before dropping to the next language is what | ||
| // keeps re-runs idempotent: a transient 403/429 on the original-language | ||
| // track must not silently promote a machine translation, because that would | ||
| // change the transcript under a bundle fingerprint that has not changed and | ||
| // the server would reject the re-upload as a content conflict. | ||
| for (let tryN = 1; tryN <= MAX_ATTEMPTS; tryN += 1) { | ||
| let stderr = ""; | ||
| for (const format of ["json3", "vtt"]) { | ||
| const dir = mkdtempSync(join(tmpdir(), "clipy-subs-")); | ||
| try { | ||
| const res = await run(bin, [ | ||
| "--ignore-config", | ||
| "--no-playlist", | ||
| "--skip-download", | ||
| attempt.flag, | ||
| "--sub-langs", | ||
| attempt.track, | ||
| "--sub-format", | ||
| format, | ||
| "-o", | ||
| join(dir, "track.%(ext)s"), | ||
| url, | ||
| ], 180_000); | ||
| if (res.code !== 0) { | ||
| stderr = res.stderr.trim(); | ||
| const line = stderr.split("\n").filter(Boolean).pop(); | ||
| if (line) | ||
| lastFailure = `${attempt.track}: ${line}`; | ||
| continue; | ||
| } | ||
| const file = readdirSync(dir).find((f) => f.endsWith(`.${format}`)); | ||
| if (!file) | ||
| continue; | ||
| const text = readFileSync(join(dir, file), "utf8"); | ||
| if (!text.trim()) | ||
| continue; | ||
| return { | ||
| format, | ||
| text, | ||
| source: attempt.source, | ||
| language: attempt.language, | ||
| ...(attempt.translatedFrom ? { translatedFrom: attempt.translatedFrom } : {}), | ||
| }; | ||
| } | ||
| finally { | ||
| rmSync(dir, { recursive: true, force: true }); | ||
| } | ||
| } | ||
| finally { | ||
| rmSync(dir, { recursive: true, force: true }); | ||
| } | ||
| const kind = classifyFailure(stderr); | ||
| if (!stderr || !isRetriable(kind) || tryN === MAX_ATTEMPTS) | ||
| break; | ||
| opts.notify?.(retryNotice(stderr, kind, tryN + 1, MAX_ATTEMPTS)); | ||
| await sleep(RETRY_BACKOFF_MS[tryN - 1] ?? 5_000); | ||
| } | ||
| } | ||
| if (lastFailure) | ||
| opts.onFailure?.(lastFailure); | ||
| return null; | ||
| } |
+102
-0
@@ -85,2 +85,104 @@ /** | ||
| ### Reading an import without drowning in it | ||
| Imports are often an hour or more. Read them in widening passes, not all at once: | ||
| 1. **Metadata + classification first.** \`clipy context read <bundle>\` starts with | ||
| the header: source, duration, the video type, whether the words stand alone, | ||
| and the timestamps the classifier flagged as blind. For a synced document, | ||
| \`get_context_document\` (MCP) returns the same thing WITHOUT the transcript — | ||
| that is the cheapest possible orientation, one tool call. | ||
| 2. **Then the sections you actually need.** The document is timestamped in | ||
| \`[MM:SS]\` sections. Once the summary tells you where the answer lives, read | ||
| that span — over MCP, \`read_context_document\` takes \`startMs\`/\`endMs\` and | ||
| returns only the sections overlapping your range, so a two-hour video costs you | ||
| two minutes of context instead of two hours. It tells you how many sections it | ||
| withheld, so you always know you're looking at a slice. | ||
| 3. **Frames last, and only where the words are blind.** Frames exist for exactly | ||
| the moments the classifier said the transcript can't carry ("click this", "the | ||
| config looks like this"). Pull them for those timestamps; don't page through | ||
| every frame hoping one is useful. | ||
| Whole-document reads are for short videos (under ~10 minutes) or when you genuinely | ||
| need every word. Assume you don't until the targeted read comes back empty. | ||
| ## When something goes wrong | ||
| Two rules that come before any specific error: | ||
| **\`--json\` on stdout is the source of truth. stderr is narration.** Progress | ||
| lines, warnings, and install disclosures go to stderr and are NOT part of the | ||
| contract — never parse them, never conclude success or failure from them. With | ||
| \`--json\`, stdout carries either the result or an error envelope: | ||
| {"ok": false, "code": "<stable code>", "error": "…", "remediation": "…", "partial": null} | ||
| Branch on \`code\`, not on the message text (messages get reworded; codes don't). | ||
| \`remediation\` is the next action, written to be run verbatim where possible. | ||
| \`partial\` carries whatever survived (e.g. \`{"bundlePath": "…"}\`) or is null. | ||
| A SUCCESS is \`{"ok": true, …}\` and always carries \`warnings\` — an array, empty | ||
| when nothing went wrong. A partial success (transcript synced, frames missing) is | ||
| \`ok: true\` with entries in \`warnings\`, each \`{code, error, remediation}\`, and | ||
| exits \`0\`. Read the warnings; do not read them as failure. | ||
| Exit codes: \`0\` ok (including partial success) · \`1\` error · \`2\` usage · | ||
| \`3\` artifact not ready. | ||
| **Never invent success.** If the command did not print a result you can read, the | ||
| work did not happen. Do not tell the user a video was imported, a recording was | ||
| made, or frames were captured because the command "seemed to run". Report what the | ||
| envelope says, including partial states. | ||
| ### The error codes | ||
| | code | what happened | what to do | | ||
| |---|---|---| | ||
| | \`invalid_url\` | the URL isn't a video Clipy can resolve | Re-read the URL with the user. Don't retry the same string. | | ||
| | \`no_captions\` | the YouTube video has no captions in any language | Nothing to transcribe from. Tell the user; offer to import a local file with \`--transcript\`, or to proceed without the video. Not retryable. | | ||
| | \`ytdlp_missing\` | yt-dlp couldn't be installed or resolved | \`clipy doctor --json\` names the path it tried. Fix: let it auto-install (it lands in \`~/.clipy/bin\`), or install manually — \`brew install yt-dlp\` / \`pipx install yt-dlp\`. Then re-run. | | ||
| | \`ytdlp_download_403\` | YouTube refused the media download | **The CLI already retried internally.** If you still see this, the transcript half may have succeeded — read the envelope for what synced. Tell the user frames are pending and the document is usable without them. Do NOT loop. | | ||
| | \`ffmpeg_missing\` | ffmpeg/ffprobe not found | \`brew install ffmpeg\` (macOS) · \`sudo apt install ffmpeg\` (Linux) · \`winget install Gyan.FFmpeg\`. Then re-run the SAME import command. | | ||
| | \`no_video_stream\` | the file has no decodable video track | Audio-only file. Import it transcript-only (\`--no-frames\`) or supply the real video. | | ||
| | \`auth_required\` | 401 — no key, or the key is invalid/revoked | Run \`clipy login\`, then **tell the user to approve the device in the browser that just opened and wait for them**. Do not retry the import until login returns. On a headless box use \`clipy login --no-browser\`. | | ||
| | \`wrong_scope\` | 403 — the key is real but lacks a permission | Write paths need the "ingest" scope. Mint a key with it at clipy.online/settings/api-keys. Re-running with the same key cannot help. | | ||
| | \`quota_exceeded\` | 429 — the account hit a limit | **Report the number to the user and stop.** Do not retry-loop; you will only burn the limit. The local bundle (if one was produced) is still yours to read. | | ||
| | \`frames_upload_failed\` | transcript synced, frames didn't | Partial success — see below. Re-run the same command later. | | ||
| | \`server_unreachable\` | the API couldn't be reached | Check \`clipy doctor --json\`'s api check. If the network is down, say so; the LOCAL bundle from a non-\`--sync\` run is still complete and readable with \`clipy context read\`. | | ||
| | \`transcript_unreadable\` | the \`--transcript\` file could not be parsed | Check it is real \`.vtt\`/\`.srt\`/Clipy transcript JSON and not empty or an HTML error page. Ask the user for the right file rather than guessing another path. | | ||
| | \`source_unreadable\` | the local video file could not be opened or probed | Verify the path exists and is readable (unmounted volume, still downloading, wrong container). Not retryable until the file is. | | ||
| | \`content_conflict\` | a different video already occupies that document | Do NOT overwrite. Show the user both and let them choose; re-running unchanged conflicts again. | | ||
| | \`unknown\` | an unclassified failure | Re-run once. If it repeats, hand the user the whole envelope as a bug report — do not improvise a workaround. | | ||
| ### The partial-success rule | ||
| **A transcript that synced with frames missing is a usable result, not a failure.** | ||
| The document exists, it's readable, and it answers most questions. When you see it: | ||
| - Tell the user plainly: imported and readable, frames pending, here's what's | ||
| missing (the classifier already named those timestamps). | ||
| - To complete it, **re-run the SAME \`clipy context import\` command** on the same | ||
| source. Imports are idempotent: it resolves to the same document and fills in | ||
| what's missing. | ||
| - **Never re-import from scratch** — no new \`--title\`, no different output dir, no | ||
| "let me try a fresh one". That produces a duplicate document and loses nothing | ||
| you gain. | ||
| - Never delete the partial document to "clean up" before retrying. | ||
| ### The three you'll actually hit | ||
| - **401 / \`auth_required\`** → \`clipy login\` opens a browser. The user has to click | ||
| approve. Wait for the command to return before doing anything else, and say out | ||
| loud that you're waiting on their browser — an agent silently blocking on a | ||
| login looks like a hang. | ||
| - **429 / \`quota_exceeded\`** → surface the quota to the user and stop. This is a | ||
| billing/limit fact, not a transient error. | ||
| - **403 on download / \`ytdlp_download_403\`** → the CLI has already retried behind | ||
| your back. Treat the import as done-but-incomplete, tell the user frames are | ||
| pending, and move on with the transcript. | ||
| When you can't tell which of these you're in, run \`clipy doctor --json\` — it | ||
| reports yt-dlp, ffmpeg, auth, and API reachability in one call and names the | ||
| missing piece instead of leaving you to guess. | ||
| ## Setup for making recordings (one time) | ||
@@ -87,0 +189,0 @@ |
+3
-3
| { | ||
| "name": "@clipy/cli", | ||
| "version": "0.9.1", | ||
| "version": "0.9.2", | ||
| "description": "Command-line interface for Clipy — list, search, and read your screen recordings' transcripts, AI summaries, and key moments from the terminal.", | ||
@@ -43,5 +43,5 @@ "license": "MIT", | ||
| "test:session": "npm run build && node scripts/auth-guard.test.mjs && node scripts/session-control.test.mjs", | ||
| "test": "npm run test:auth && npm run test:session && node scripts/context-sync.test.mjs && node scripts/context-frames.test.mjs", | ||
| "test": "npm run test:auth && npm run test:session && node scripts/context-sync.test.mjs && node scripts/context-frames.test.mjs && node scripts/context-youtube-transcript.test.mjs && node scripts/context-youtube-url-lang.test.mjs && node scripts/context-retry.test.mjs && node scripts/context-json-envelope.test.mjs && node scripts/context-ytdlp-update.test.mjs", | ||
| "prebuild": "node scripts/sync-context-core.mjs", | ||
| "test:context": "npm run build && node scripts/context-sync.test.mjs && node scripts/context-frames.test.mjs" | ||
| "test:context": "npm run build && node scripts/context-sync.test.mjs && node scripts/context-frames.test.mjs && node scripts/context-youtube-transcript.test.mjs && node scripts/context-youtube-url-lang.test.mjs && node scripts/context-retry.test.mjs && node scripts/context-json-envelope.test.mjs && node scripts/context-ytdlp-update.test.mjs" | ||
| }, | ||
@@ -48,0 +48,0 @@ "devDependencies": { |
Sorry, the diff of this file is too big to display
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
475084
13.5%22
15.79%8740
12.28%51
2%23
4.55%