Repository navigation
feat(tools): add vdl downloader + Siphon web front end - #16
cloudygetty-ai wants to merge 2 commits into
Conversation
Wraps yt-dlp with the operational layer it does not provide: classified
errors driving retry policy, bounded concurrency, partial-failure isolation,
and structured run telemetry.
- config.py frozen DownloadConfig, validated once at the boundary
- formats.py format selectors that degrade progressively so single-stream
sites still resolve instead of erroring on a hard constraint
- errors.py taxonomy + retry classification + stable exit codes
- batch.py thread-pool execution; one failed URL never stops the run
- telemetry.py HEALTH / PRESSURE / EFFICIENCY, JSON to stderr
- ffmpeg.py system ffmpeg, falling back to the imageio-ffmpeg binary so
merging and audio extraction work with no system packages
Retry policy is the load-bearing detail: yt-dlp reports a permanent 404 and a
timed-out socket with the same "Unable to download webpage" prefix, so the
classifier parses HTTP status out of the message and retries only what can
succeed. Measured on a dead URL: 10s of pointless backoff down to 1.6s.
Verified end to end against CC-licensed Blender demo media — download, mp3
extraction, container remux, batch with an intentional failure, and telemetry
output. 79 tests, no network required (yt_dlp.YoutubeDL is stubbed).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01US2uXf9esJvHouvwvvUxCB
CI is red on a pre-existing
|
| Step | Result |
|---|---|
npm run type-check |
passes |
npm test |
107 passed, 8 suites |
npm run lint |
79 errors — pre-existing on main |
Why I'm not fixing it here
73 of the 79 are auto-fixable with npx eslint . --ext .ts,.tsx --fix (pure whitespace/formatting). But that rewrites 8 battle-royale source files that have nothing to do with a downloader, turning a self-contained additive PR into a repo-wide reformat and burying the reviewable change. Better as its own PR.
Happy to open that separately if wanted — it should be a one-command mechanical commit.
Generated by Claude Code
Siphon — the vdl engine behind a static UI plus one serverless function. Resolution, not proxying: Vercel functions cap at 60s with no persistent disk, so streaming full videos through one fails on anything sizable. The function probes the URL and returns direct stream URLs; the browser fetches from the origin CDN. The server never touches video bytes. - api/resolve.py URL validation + stream resolution, shared error taxonomy - public/index.html obsidian/gold UI, Cinzel + DM Mono per house style - vercel.json 60s/1024MB function budget, nosniff/DENY/no-referrer validate() is the security boundary. The function feeds caller-supplied URLs to yt-dlp, which fetches whatever it is handed — an SSRF gadget pointed at Vercel's internal network unless fenced. Scheme, length, and every resolved address are checked before extraction; private, loopback, link-local, reserved and multicast space are refused, including 169.254.169.254. If DNS returns several addresses and any one is internal, the request is rejected. Two bugs found while verifying against real media: - _kind() read vcodec=None as "no video track", but yt-dlp uses the literal string "none" for absent and None for unprobed. A plain .avi came back labelled audio-only. Unknown now reads as present. - _label() hardcoded "audio only" whenever height was missing, mislabelling the same generic-extractor case. It now derives from the stream kind. requirements.txt narrowed to yt-dlp so the lambda does not ship a 25MB ffmpeg binary it never invokes; ffmpeg stays an optional CLI extra. 114 tests (35 new), all offline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01US2uXf9esJvHouvwvvUxCB
What
Adds
tools/video-downloader/— a video/audio downloader built on yt-dlp (1800+ supported sites), in two forms:vdl— the CLIyt-dlp handles extraction. This adds the operational layer around it: errors classified so retry policy is correct, bounded concurrency, partial-failure isolation, and structured telemetry.
Architecture
One responsibility per module; every file under the 200-line review threshold except
cli.py(215, argument definitions rather than logic).config.pyDownloadConfig— frozen, validated once at the boundaryformats.pyoptions.pyDownloadConfig→ yt-dlp options dict (pure, fully testable)downloader.pybatch.pytelemetry.pyffmpeg.pyerrors.pyapi/resolve.pypublic/index.htmlThe load-bearing detail
yt-dlp reports a permanent 404 and a timed-out socket with the same
Unable to download webpageprefix. A naive classifier retries both. The first build did exactly that — a dead URL burned the full retry budget with exponential backoff before failing.The classifier now parses HTTP status out of the message and orders matching so unrecoverable cases are caught first: 401/403 → protected, 404/410/451 → not found, 429/5xx → retryable. Measured on a dead URL: 10s → 1.6s.
Exit codes are stable for scripting:
0all ok,1partial failure,2bad config,3unsupported URL,4protected content,5network,6merge,7not found.Siphon (web)
Paste a URL, get every downloadable stream as a direct link. Obsidian/gold, Cinzel + DM Mono per house style.
Why it resolves instead of proxying. Vercel functions cap at 60s with no persistent disk, so streaming a full video through one fails on anything sizable. The function probes and returns direct stream URLs; the browser fetches from the origin CDN. The server never touches video bytes — no egress bill, no timeout.
validate()is the security boundary. The function feeds caller-supplied URLs to yt-dlp, which fetches whatever it is handed — an SSRF gadget pointed at Vercel's internal network unless fenced. Scheme, length, and every resolved address are checked before extraction; private, loopback, link-local, reserved and multicast space are refused, including the169.254.169.254metadata endpoint. If DNS returns several addresses and any one is internal, the request is rejected.Two bugs found while verifying against real media
_kind()readvcodec=Noneas "no video track", but yt-dlp uses the literal string"none"for absent andNonefor unprobed. A plain.avicame back labelled audio-only. Unknown now reads as present._label()hardcoded"audio only"whenever height was missing, mislabelling the same generic-extractor case. It now derives from the stream kind.Testing
114 tests, no network required —
yt_dlp.YoutubeDLis replaced with a scripted stub, so retry policy, classification, and concurrency are verified offline. The SSRF guard has its own 20-case suite.Also verified end to end against CC-licensed Blender demo media: 8.1 MB download at 4.5 MB/s with ffmpeg metadata pass, audio extraction → 1.3 MB mp3, batch of 2 with one intentional failure (1 ok / 1 failed, exit
1, good URL unaffected),--json-logstelemetry, and--simulate.Notes for review
tools/. No dependency on the React Native game, no changes to existing files, nothing added topackage.json, and the rootvercel.json(the game's landing page) is untouched.requirements.txtnarrowed to yt-dlp so the lambda doesn't ship a 25 MB ffmpeg binary it never invokes; ffmpeg stays an optional CLI extra.mainfailure, not this diff — see the comment below.type-checkandnpm test(107 passing) are clean;lintfails identically on pristineorigin/main.🤖 Generated with Claude Code
https://claude.ai/code/session_01US2uXf9esJvHouvwvvUxCB