Raw capture โ raw/castor.md
url: https://github.com/stupside/castor title: Castor author: stupside date_fetched: 2026-07-25 date_published: unknown
A Go CLI (~10,640 lines) that casts web video to smart TVs at full quality by extracting the real video stream URL, transcoding it for the target device, and serving it over the local network. Written by stupside. Licensed under MIT.
castor/
main.go โ entry point: signal-aware context, structured logging
cmd/ โ CLI tree (urfave/cli v3):
cmd.go โ root command, lazy config loading with sync.Once
cmd-cast.go โ cast subcommands: interactive, player, url, movie, episode
cmd-scan.go โ device discovery
internal/
config/ โ YAML config loading (koanf), typed composition
cast/ โ the cast pipeline (planner/executor):
cast.go โ Play entry point: resolve โ plan โ run
plan.go โ BuildPlan: DLNA vs Chromecast decisions
pipeline.go โ runSpooled: pull โ spool โ transcode โ serve
source.go โ upstream pull (ffmpeg remux โ spool)
deliver.go โ runDirect, serveToDevice
subtitle.go โ whisper transcription + drawtext burn-in
gate.go โ playback gate: waits for whisper lead + spool data
config.go โ typed config compose-and-forward pattern
spool/ โ append-only on-disk buffer with blocking tails
replay/ โ HTTP server replaying spool from byte 0 per client
whisper/ โ whisper.cpp Go bindings for live transcription
cue/ โ subtitle word โ cue shaping (timing, wrapping)
ffmpeg/ โ ffmpeg command builders, encoder registry, process mgmt
device/ โ device discovery (SSDP, mDNS) and control protocols:
device.go โ Device interface, Discover, Connect, FindInfo
dlna.go โ DLNA/UPnP AVTransport + ConnectionManager negotiation
chromecast.go โ Chromecast (experimental)
source/
extract/ โ headless Chrome stream extraction:
extractor.go โ CDP-based network traffic capture
collector.go โ stream URL collection, dedup, ranking
session.go โ browser lifecycle, navigation, action pipeline
stealth.go โ anti-detection (WebGL, canvas, font metrics, etc.)
turnstile.go โ Cloudflare Turnstile bypass
js/ โ injected JavaScript (17 stealth/modification scripts)
resolve/ โ stream resolution and ranking:
resolve.go โ HLS variant selection, bandwidth-based ranking
probe.go โ ffprobe-based stream inspection
hls.go โ HLS playlist parsing
media/ โ shared types: Stream, ProbeInfo, Renderer, content types
browse/ โ Bubble Tea TUI for TMDB browsing:
model.go โ main model: tabs, search, discover, drilldown
inspector.go โ poster + metadata panel
genre.go โ genre filter modal
drilldown.go โ TV seasons โ episodes navigation
picker.go โ device picker
discover.go โ TMDB discover with genre/sort filters
tmdb/client.go โ TMDB API client
third_party/whisper.cpp/ โ vendored whisper.cpp submodule (cgo bindings)
Key direct dependencies:
- chromedp/chromedp + chromedp/cdproto โ headless Chrome via DevTools Protocol
- charmbracelet/bubbletea, bubbles, lipgloss โ TUI framework
- ggerganov/whisper.cpp/bindings/go โ vendored via replace directive (cgo)
- huin/goupnp โ SSDP discovery + UPnP SOAP actions
- vishen/go-chromecast โ Chromecast control
- knadh/koanf โ configuration loading (YAML + env)
- urfave/cli/v3 โ CLI framework
- golang.org/x/sync โ errgroup for pipeline coordination
Requires Chrome/Chromium, ffmpeg 7.1+, and ffprobe 7.1+ on PATH.
Pattern: Single-binary CLI โ extraction โ resolution โ cast pipeline (planner/executor with async device connection).
The cast pipeline is the heart of the project. It uses a planner/executor split: BuildPlan (cast/plan.go) makes every decision up front based on device type and source properties, producing an immutable Plan struct. The executor (cast/pipeline.go) wires stages together with zero branching on device type or codec โ it just follows the plan.
DLNA path (the primary, well-tested path):
1. pull (source.go): One ffmpeg process remuxes the source into MPEG-TS on the spool (codec-copy, cheap). Paced at 2ร realtime after a 90-second initial burst for VOD, 1ร for live. Optionally tees mono PCM for whisper.
2. spool (spool/spool.go): Append-only disk buffer. Writes append, tails block at end-of-data via sync.Cond until more bytes arrive. Crucially, ffprobe can probe the growing spool locally โ no upstream round-trip.
3. gate (gate.go): Blocks until whisper has a 10+ second transcription lead (or is done) AND the spool has data. Also detects upstream stalls via a 60-second progress watchdog.
4. Device discovery runs concurrently with pull+gate via deviceFuture โ SSDP and connect can take seconds that short-lived signed URLs can't spare.
5. Local spool probe decides copy-vs-encode: if the source codec matches the renderer's capabilities and fits within the height cap, video is stream-copied; otherwise re-encoded through hardware encoder auto-detection (VA-API, VideoToolbox, or software libx264/libx265).
6. replay server (replay/server.go): HTTP server spooling the encoder output to disk and replaying it from byte 0 to every client. This is what survives Samsung's HEAD-probe โ short-GET โ real-GET dance.
7. DLNA device control via SOAP actions: SetAVTransportURI + Play.
Chromecast path: Simpler โ can pass-through when the device accepts the source container. Otherwise remuxes (video copied, audio to AAC). Subtitles unsupported due to library limitation.
Stream extraction (source/extract/): - Launches headless Chrome via chromedp - Injects anti-detection JavaScript (17 scripts covering WebGL, canvas, font metrics, WebRTC, notifications, plugins, permissions, etc.) - Collects stream URLs from CDP network events (requestWillBeSent + ExtraInfo for headers, responseReceived for confirmed MIME types) - Also scrapes HLS .m3u8 URLs from console.log output - Runs an action pipeline: click โ navigate into largest iframe โ bypass Turnstile โ click fallback - URLs are deduplicated, scored (master playlists preferred over variants), and sorted
Stream resolution (source/resolve/): - Probes streams with ffprobe (concurrent, bounded by semaphore) - For HLS masters: fetches the playlist, parses variants, picks highest-bandwidth within height cap - Ranks streams: decoys (no video+audio, too short/ad insert) dropped; within-cap preferred; ties broken by bandwidth - Per-host probe cap (maxProbePerHost=5) prevents rate-limiting on embed proxies
The single most important architectural decision. The upstream CDN is touched exactly once: a single ffmpeg process pulls the source, remuxes it codec-copy to MPEG-TS on disk, and optionally tees PCM audio. Everything downstream โ encode, whisper, the replay server โ reads local data. This means: - CDN token expiration can't kill playback mid-movie - Rate limits are irrelevant after the initial fetch - The puller is paced like a buffering player (2ร realtime, 90s initial burst), so the CDN sees a well-behaved client, not a ripper
The spool's blocking tail (spool.go:104-133) uses sync.Cond.Wait() to block readers at end-of-data until more bytes arrive โ a Unix tail -f primitive in Go. Unlike a pipe, multiple independent readers can each start from byte 0.
The device is discovered and connected concurrently with the upstream pull (pipeline.go:46). Device discovery (SSDP + connect + GetProtocolInfo) can take seconds โ seconds that signed source URLs can't spare between extraction and first byte. The deviceFuture type uses a single-slot channel and errgroup integration: connect failure cancels the group cleanly; an unclaimed device is closed during teardown.
The subtitle system has three concurrent goroutines:
1. The puller tees mono PCM (16kHz s16le) to whisper
2. whisper transcribes with LocalAgreement-2 streaming policy (commit only word prefixes confirmed by two consecutive hypotheses)
3. The encoder's -progress feed drives a cue writer that looks up the active subtitle line at out_time + 1s bias and atomically renames a temp file over the cue text file
ffmpeg's drawtext filter with reload=1 re-opens the textfile before every frame. The atomic rename (write tmp, os.Rename) prevents ffmpeg from reading a partially-written file and crashing. This is a remarkably clean way to inject live text into a video pipeline โ no IPC protocol, just a filesystem atomic swap.
The encoder is paced at 1.15ร realtime via -readrate so the cue writer can keep up; the playback gate (gate.go) ensures whisper has a 10-second lead before encoding starts.
Rather than guessing from the platform, SelectEncoder (ffmpeg/encoder.go:73-87) runs a real one-frame test encode through each hardware encoder candidate (VA-API, VideoToolbox) and caches the result. A working GPU is proven once; a wedged one isn't retried per cast. The encoder registry is codec-ordered with hardware candidates ahead of software baselines โ selection tries HEVC on GPU first, then H.264 on GPU, then software.
Rather than assuming per-device-type capabilities, the DLNA path negotiates the renderer's actual capabilities at connect time via UPnP ConnectionManager GetProtocolInfo (dlna.go:128-151). The Sink protocolInfo CSV is parsed to extract supported codecs (AVC/HEVC from DLNA.ORG_PN tokens) and containers. The result drives the copy-vs-encode decision. Fallback to conservative H.264+MPEG-TS if negotiation fails.
Captured URLs from CDP events are scored (collector.go:298-324): master playlists get +100, "playlist" paths +50, variant/segment paths -50. When a master playlist is detected, the collection window exits immediately โ no need to wait for individual variants. Duplicate URLs enrich existing entries (attach request headers from later sightings for hotlink-protected hosts).
The replay server (replay/server.go) spools the encoder's output and serves every HTTP client from byte 0 through an independent spool tail. This solves the Samsung-specific problem: the TV sends a HEAD, then a short GET (probe), then a real GET โ without replay, the probe would consume the stream head and the real GET would join mid-stream at an undecodable byte offset. With replay, each connection gets its own copy from the start.
The gate (gate.go) blocks playback until the pipeline is ready, with a 60-second stall timeout. When the upstream stalls, it surfaces ffmpeg's stderr ring buffer โ capturing "Server returned 404" patterns from expired HLS segments that would otherwise be hidden by context cancellation killing the ffmpeg process before it logs them.
Optimized for DLNA, Chromecast is experimental: The README is honest about this. The DLNA path has spool, replay server, capability negotiation, and subtitle support. The Chromecast path is a simpler direct-feed path with no subtitles (blocked by a library limitation in vishen/go-chromecast).
Single upstream connection: The puller remuxes the source exactly once. No retries, no parallel fetches. This is the right call for short-lived signed URLs โ every second between extraction and first byte increases the chance of token expiry.
Always remux to MPEG-TS for DLNA: MPEG-TS is strictly append-only (no trailer), making it the right spool format. ffmpeg auto-inserts the correct bitstream filter (h264_mp4toannexb vs hevc_mp4toannexb) โ no hardcoded assumptions.
Paced like a player, not a downloader: The puller reads at 2ร realtime (VOD) or 1ร (live). This is slower than possible, but it keeps CDN rate limits happy and the spool as a buffer rather than a full download. For VOD, the initial 90-second burst fills the spool quickly, then 2ร pacing means the spool only grows โ the encode always has data.
Subtitles as hardsubs, not sidecar tracks: Samsung renderers can't be trusted to display DLNA-delivered caption tracks, so subtitles are burned into the video via drawtext. The Chromecast path has no subtitles at all (library limitation).
Config composition: Each domain package owns its config type; the top-level config composes them (config/config.go:28). The cast package never imports app-level state โ it takes a cast.Config that the caller composes from the application config. Clean dependency inversion.
Go 1.26 requirement: Notably uses Go 1.26 features (newer than the stable release at time of analysis), including slices.Concat, slices.SortedFunc, cmp.Or, and iter.Seq patterns.
Unlike browser automation tools like [[Browser Use]] and [[Webwright]] which are general-purpose, Castor uses browser automation for a specific purpose: stream URL extraction. It's not an agent framework โ it's a deterministic pipeline wrapped in a CLI.
Unlike [[surf-cli]] which provides general browser automation for agents, Castor's Chrome usage is tightly scoped: launch, navigate, capture CDP network events, run a fixed action pipeline, extract. The anti-detection suite (17 JS scripts) is unusually thorough for a non-scraping tool.
The transcription pipeline (whisper.cpp LocalAgreement-2 โ cue shaping โ drawtext burn-in) is a genuinely novel engineering achievement: real-time speech-to-subtitle burned into a live video transcode, all in-process, with atomic filesystem coordination between encoder and transcriber. Most tools that do live subtitling use sidecar protocols (WebVTT, SRT sidecars) โ Castor burns them in because Samsung TVs don't reliably display DLNA caption tracks.
The spool-based read-once pipeline is a distinctive architectural choice. Most streaming tools either buffer entirely in memory (fragile) or serve directly from the upstream URL (token-expiry-risky). Castor's spool is an append-only disk buffer that decouples the CDN from playback โ once data is spooled, the CDN can go away and playback continues.