Smart TVs won't cast arbitrary web video, and screen mirroring is laggy. Castor puts the video from a web page or a link on your TV, even when the TV can't play it as it is, with generated subtitles if you want them.
Use it on your computer alone, or run a castor media server on a machine that stays on, such as a NAS, and cast through it from every computer at home.
A general-purpose casting tool: it casts only what you point it at. See Purpose and disclaimer.
Run castor cast to browse titles and cast, without leaving the terminal.
1. Install (macOS. Other options)
brew install --cask stupside/tap/castor2. Find your TV
castor scan3. Save it to config.yaml, in the directory you run castor from
device:
name: "Living Room TV" # exact name from `castor scan`
type: dlna # or: chromecast, roku4. Cast
castor cast player https://example.com/watch/some-video
castor scanfound nothing? See Troubleshooting.
| Command | What it does |
|---|---|
castor scan |
List the devices on your network |
castor cast player <url> |
Cast a web page with an embedded video player |
castor cast url <url> |
Cast a direct stream or video URL |
castor cast |
Browse titles and cast, interactively (needs a TMDB key) |
castor cast movie <id> |
Resolve a movie id against your sources and cast |
castor cast episode <id> --season N --episode N |
Same, for a TV episode |
castor media-server |
Run the media server other computers cast through (another machine) |
castor api-server |
Serve castor's API, for apps and integrations (another machine) |
castor cast --dry-run ... prints the streams it found instead of casting. Run castor --help for all flags.
Castor runs best as a native binary on the same network as your TV. It needs three tools on your PATH:
| Tool | Version | Used for |
|---|---|---|
| Chrome / Chromium | Any recent | Finding the video on a page |
| ffmpeg | 7.1+ (older builds reject flags castor uses) | Converting the video for your TV |
| ffprobe | 7.1+ | Reading the video's format |
Castor converts video on the GPU when ffmpeg can (VideoToolbox, NVENC, Quick Sync, VA-API or AMF, whichever works first), and in software otherwise.
- macOS:
brew install --cask stupside/tap/castor. - Linux: download
castor_<version>_linux_amd64.tar.gz(or_arm64) from the latest release and putcastoron yourPATH. - Windows: download
castor_<version>_windows_amd64.zip(or_arm64), extractcastor.exeinto a folder on yourPATH, and install the tools withwinget install Gyan.FFmpegandwinget install Google.Chrome. On first run, SmartScreen may block the unsigned binary (choose More info, then Run anyway), and the firewall asks about network access: allow it on private networks, or Castor finds no devices. - From source: see CONTRIBUTING.md.
Castor reads config.yaml from the working directory (or --config <path>). Casting from the command line needs device; every other key has a default. Any key can also be set as a CASTOR_SECTION__FIELD environment variable, e.g. CASTOR_CAST__MAX_HEIGHT=720.
Tip
Keep secrets (keys, tokens, passwords) in a git-ignored config.local.yaml, which overlays config.yaml, or in environment variables. See SECURITY.md.
Generated subtitles, burned into the video. The model downloads once to your user cache.
cast:
subtitles: en # a language code, or auto to detect it; unset for none
whisper:
# model_path: "" # default: ggml-tiny.en (~75 MB, English only)For another language or auto, point whisper.model_path at a multilingual whisper.cpp model (e.g. ggml-base.bin).
Note
Burn-in applies only to devices castor always streams to, such as DLNA. A device that fetches the video itself (Chromecast, Roku) gets none, even when castor relays it.
Set max_height to your TV's vertical resolution: castor picks the tallest stream that fits and scales down anything taller it knows about. Raise it if you'd rather the TV play a taller video as it is.
cast:
max_height: 2160 # default: 1080cast movie, cast episode, and the interactive browser turn a title id into a page URL. Castor bundles no sources: you add your own, for sites you are authorized to use. The id is substituted into your templates under each of your proxies, and the page is cast like cast player.
sources:
- proxies: ["https://your-source.example"] # base URLs, tried in order
templates:
movie: "/embed/movie/{itemID}"
episode: "/embed/tv/{itemID}/{season}-{episode}"So castor cast movie tt12300742 opens https://your-source.example/embed/movie/tt12300742.
Only the interactive browser (castor cast) needs one. Get a free key from themoviedb.org:
tmdb:
api_key: "<KEY>"A machine that stays on, such as a NAS, does the heavy work; your computer finds the TV and drives it. On the server, with a token in its config:
castor media-server # listens on :8410 (server.listen)On your computer:
server:
url: http://my-nas:8410
token: "<a long random string, the same on both>"Once the TV plays, closing the terminal leaves the cast playing; Ctrl+C stops it. Subtitles, quality and delivery come from your computer's config; the whisper model is the server's. For a server outside your home network, set where the TV reaches it with server.advertise (e.g. http://castor.example.com:8410) and put it behind HTTPS.
Everything castor does to your TVs is an API: list them, cast a link or a page on one, follow the cast, stop it. Run it on a machine on your TV's network, casting through a media server (the shared one, or one beside it on the same machine):
castor media-server # listens on :8410 (server.listen)
castor api-server # listens on :8411 (api.listen), with server.url: http://localhost:8410curl -X POST http://localhost:8411/castor.v1.DeviceService/ListDevices \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{}'
curl -X POST http://localhost:8411/castor.v1.CastService/Cast \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"target": {"deviceId": "<id from ListDevices>"}, "source": {"stream": {"url": "https://example.com/video.m3u8"}}}'Set api.token once the API leaves your machine; the header is needed only then. A page instead of a link is "source": {"pages": {"urls": ["https://example.com/watch"]}}. End a cast with Stop. Watch follows a cast to its end; it is a server stream, so use buf curl, grpcurl, or a client generated from the contract with buf. Point other castor commands at it with api.url and api.token.
| Protocol | Works with | Status |
|---|---|---|
DLNA / UPnP (MediaRenderer:1) |
Most smart TVs, and players like Kodi, VLC, and Plex | Tested on Samsung |
| Chromecast | Google Cast devices | Experimental, not yet tried on real hardware |
| Roku | Roku TVs and players, through a sideloaded channel | Experimental, not yet tried on real hardware |
Roku can't play an arbitrary URL from a preinstalled app, so Castor installs a small channel of its own:
- Turn on Developer Mode (once, by hand): on the remote press Home x3, Up x2, Right, Left, Right, Left, Right, enable developer mode, and set a web-server password. The device reboots.
- Put the password in your config (out of git, see Configuration):
devices: roku: password: "<dev-web-server-password>" # first cast only
- Cast. Castor sideloads its channel automatically; later casts reuse it.
Already published the channel to your account? Set devices.roku.app_id to its numeric id instead: no dev mode, no password. When castor relays the video, a Roku plays about 30 s behind.
Discovery doesn't cross VLANs or subnets, and is blocked on Android/Termux (netlinkrib: permission denied). Pinning a host reaches the device directly, and skips the discovery wait.
device:
name: "Living Room TV" # now just a label
type: dlna
host: 192.168.0.3 # the device's LAN IPIf a DLNA TV doesn't answer at its IP, use its full description URL instead (e.g. http://192.168.0.3:9197/dmr). On Android/Termux, also leave network.interface empty (the default): pinning one needs the same blocked interface lookup.
Castor plays the page to find its video, so it only works on pages whose video starts without a click. DRM-protected streams are refused.
Have castor always send the video itself, rather than handing the TV the link:
cast:
delivery: serve # "auto" (the default) decides per sourceRelaying costs bandwidth and CPU, so try it once first with CASTOR_CAST__DELIVERY=serve. castor --debug logs which way each cast went and why.
The ghcr.io/stupside/castor image is the full castor with Chrome, ffmpeg, and ffprobe. It converts video on an Intel GPU through VA-API when you pass --device /dev/dri, and in software otherwise.
Warning
Discovery and the API server need --network host, which Docker Desktop (macOS/Windows) ignores, so scan finds nothing there. Use the native binary instead.
docker run --rm --network host ghcr.io/stupside/castor:latest scan
docker run --rm --network host --device /dev/dri \
-v "$PWD/config.yaml:/config.yaml" \
-v castor-cache:/root/.cache \
ghcr.io/stupside/castor:latest \
cast player https://example.com/watch/some-videoThe castor-cache volume keeps downloaded whisper models. To keep a server running, pass -d with server (and -e CASTOR_SERVER__TOKEN=<token>) or api (and -e CASTOR_API__TOKEN=<token>). A lone server can also run without host networking: publish -p 8410:8410 and set server.advertise. Health checks (grpc.health.v1.Health) need no token, so an orchestrator can probe either server.
docker-compose.yml runs media-server and api-server as separate services on the host network: put CASTOR_SERVER__TOKEN and CASTOR_API__TOKEN in a git-ignored .env, then docker compose up -d.
Tags: :latest (stable), :canary (preview), or a pinned :vX.Y.Z.
- It hosts nothing. No bundled video, catalog, or sources. Castor casts only what you supply and are authorized to use.
- It does not touch DRM. It never decrypts or circumvents DRM, and refuses protected streams.
- Lawful use is your responsibility. Check a site's terms and your local law. Provided as-is for lawful, personal, and educational use.
See CONTRIBUTING.md, and ARCHITECTURE.md for how castor works inside.