# youtube-legend-cli
[](https://docs.rs/youtube-legend-cli)
[](https://crates.io/crates/youtube-legend-cli)
[](CHANGELOG.md)
[](LICENSE)
[](Cargo.toml)
[](https://crates.io/crates/youtube-legend-cli)
[](https://www.rust-lang.org)
## What Is It
- `youtube-legend-cli` is a single Rust binary that turns a YouTube URL into a clean subtitle file
- It is non-interactive, ships no daemon, and never phones home
- It speaks plain HTTP to third-party subtitle providers, with no browser and no headless runtime
- The interface is pure Unix: the URL on argv or on `stdin`, the subtitle body on `stdout`, logs and progress on `stderr`
- The version in `Cargo.toml` is `0.4.0`
## Why
- You want the subtitle text, and you do not want to run a browser to get it
- You want a command that behaves the same inside a script, a Makefile and an agent loop
- You want machine-readable failure, so `--json` gives you a stable `kind` instead of prose you have to parse
- You want the process to tell the truth through its exit code, so downstream tooling can branch without reading text
- You want configuration you can inspect, so `config list-keys` prints the whole registry instead of a wiki page
- You want zero surprises about state, so the binary reads NO environment variable at all
## Superpowers
- One positional `[URL]` accepts the watch, shorts, embed and `youtu.be` forms
- Three output formats behind `--format`: `txt`, `srt` and `vtt`
- Language negotiation through `--lang`, in ISO 639-1 or BCP 47 form
- Two real providers plus an `auto` chain that walks them in cost order
- A local file cache with a TTL you control through `--cache-ttl`
- Batch mode that reads one URL per line from `stdin`, with `--jobs` concurrency and `--resume`
- A structured JSON envelope on `stdout` under `--json`, for success AND for failure
- Agent-native output shaping with `--select`, `--filter`, `--limit`, `--sort`, `--dedupe-by` and `--count-only`
- Output budgets with `--truncate-content` and `--max-output-bytes`, so an agent never drowns in a transcript
- A self-describing surface through `--print-schema`, `config list-keys`, `completions` and `man`
- Unicode NFC normalisation on the emitted transcript text
- Cooperative signal handling: `SIGINT` exits 130 and `SIGTERM` exits 143, both declared in `src/main.rs`
- Zero telemetry, zero analytics, zero call home
## Quick Start
```bash
# Install from crates.io
cargo install youtube-legend-cli
# Subtitles for one video, plain text on stdout
youtube-legend-cli "https://youtu.be/dQw4w9WgXcQ" > subtitle.txt
# Keep the timestamps
youtube-legend-cli --format srt "https://youtu.be/dQw4w9WgXcQ" > subtitle.srt
# Brazilian Portuguese
youtube-legend-cli --lang pt-BR "https://youtu.be/dQw4w9WgXcQ"
# Structured JSON on stdout
youtube-legend-cli --json "https://youtu.be/dQw4w9WgXcQ"
# Batch mode, one URL per line on stdin
youtube-legend-cli --batch < urls.txt > subtitles.txt
```
## Installation
```bash
# From crates.io
cargo install youtube-legend-cli
# From a local checkout
cargo install --path .
# Verify
youtube-legend-cli --version
```
- The Minimum Supported Rust Version is 1.88.0, declared by `rust-version` in `Cargo.toml`
- `rust-toolchain.toml` pins the local development toolchain, which is a different and newer channel
- This project has NO CI, so nothing enforces the MSRV for you: build and test on that version yourself
- The crate ships exactly one binary, named `youtube-legend-cli`
## Usage
### Streams
- `stdout` carries the subtitle body, or the `--json` envelope
- `stderr` carries logs, progress and human-readable error messages
- MEASURED on 2026-09-04 with `--json` against `not-a-url`: the JSON error object leaves on `stdout` and the exit code is 65
- `stdin` accepts one URL, or a batch of one URL per line under `--batch`
- `--no-input` refuses `stdin` and fails fast instead of blocking
### Input And Output Flags
- `[URL]` is the positional argument, in watch, shorts, embed or `youtu.be` form
- `--lang <LANG>` sets the preferred subtitle language, ISO 639-1 or BCP 47, default `en`
- `--ui-lang <LANG>` sets the interface language for messages on `stderr`
- `--format <FORMAT>` picks `txt`, `srt` or `vtt`, default `txt`
- `--json` emits structured JSON on `stdout`
- `--batch` reads multiple URLs from `stdin`, one per line
- `--resume` skips URLs whose output a previous `--batch` run already emitted
- `--jobs <N>` sets how many batch items run concurrently, default `0`, which derives the number from the host
- `--no-input` refuses `stdin` and fails fast
- `--yes` assumes yes for any confirmation prompt
- `--print-schema` prints the JSON Schema of every output surface and exits
### Network And Cache Flags
- `--timeout <SECONDS>` caps the WHOLE operation, not one HTTP request, default `300`
- `--provider <PROVIDER>` accepts `auto`, `provider-decopy` or `provider-noiz`, default `auto`
- `--user-agent <STRING>` sets a custom User-Agent for HTTP requests
- `--cache-ttl <HOURS>` sets the local cache TTL, default `24`
- `--no-cache` disables reads from the local cache
- `--dry-run` skips network I/O and serves reads from cache only
- `--offline` refuses every outbound request and serves reads from the local cache only
### Logging And Presentation Flags
- `--verbose` emits tracing events to `stderr`
- `--quiet` suppresses all `stderr` output except errors
- `--log-level <LEVEL>` accepts `error`, `warn`, `info`, `debug` or `trace`, default `warn`
- `--log-format <FORMAT>` accepts `text` or `json`, default `text`
- `--color <WHEN>` accepts `auto`, `always` or `never`, default `auto`
- `--no-progress` suppresses progress bars on `stderr`
- `--config <PATH>` points at a TOML config file
### Agent-Native Shaping Flags
- `--select <KEYS>` keeps only these comma-separated dotted keys, and carries the alias `--fields`
- `--filter <EXPR>` filters elements by `key=value`, `key!=value` or `key~substring`
- `--limit <N>` emits at most N elements
- `--sort <KEY>` sorts ascending by a dotted key
- `--dedupe-by <KEY>` drops later elements repeating that key's value
- `--count-only` replaces the payload with a count
- `--truncate-content <N>` shortens strings above N characters, never bytes
- `--max-output-bytes <N>` caps the envelope at N bytes by dropping whole elements
### Help Flags
- `-h` and `--help` print help
- `-V` and `--version` print the version
### Providers
- `provider-decopy` pins the decopy.ai provider, which serves the native track only and offers no language choice
- `provider-noiz` pins the noiz.io provider, which is subject to a five-per-day quota
- `auto` is the default and walks the whole chain in cost order
- There are exactly TWO providers, and no browser is involved in either
```bash
# Default, auto walks the whole chain
youtube-legend-cli "https://youtu.be/dQw4w9WgXcQ"
# Pin one provider
youtube-legend-cli --provider provider-decopy "https://youtu.be/dQw4w9WgXcQ"
```
## Commands
- The binary exposes exactly THREE subcommands
- `config` inspects and edits the configuration file
- `completions <SHELL>` prints the shell completion script on `stdout`
- `man` prints the section 1 manual page on `stdout`, in roff format
### config
- `config path` prints the absolute path of the configuration file
- `config show` prints every key that is set, in dotted form
- `config get <KEY>` prints the value of one key
- `config set <KEY> <VALUE>` sets one key, validated against the registry
- `config unset <KEY>` removes one key, restoring the compiled default
- `config list-keys` prints the whole registry: key, type and description
```bash
youtube-legend-cli config path
youtube-legend-cli config list-keys
youtube-legend-cli config set lang pt-BR
youtube-legend-cli config get lang
youtube-legend-cli config unset lang
youtube-legend-cli config show
```
### completions
- `completions` accepts exactly FIVE shells: `bash`, `elvish`, `fish`, `powershell` and `zsh`
- The script is derived from the `clap` command tree, so it never drifts from the flags above
```bash
youtube-legend-cli completions zsh > _youtube-legend-cli
youtube-legend-cli completions bash > youtube-legend-cli.bash
```
### man
- `man` prints the section 1 page in roff format on `stdout`, derived from the same command tree
```bash
youtube-legend-cli man > youtube-legend-cli.1
```
## Configuration
- The binary reads ZERO environment variables to govern its behaviour
- `RUST_LOG` was the fallback for `--log-level` until 2026-08-31 and is NOT read any more
- Any document that teaches you an environment variable for this CLI is wrong
- Configuration happens in exactly two places: a CLI flag, and the TOML file that `config` owns
- Run `config path` to find that file, `config list-keys` to see every key it accepts, and `config set` to change one
- A CLI flag always wins over the file
- `--config <PATH>` points the run at a different TOML file
## Exit Codes
- The CLI follows the BSD `sysexits.h` convention, mapped in `src/error.rs`
- `0` means success
- `2` means the command line was refused before the run started, with `kind` equal to `invalid_usage`
- `64` is `EX_USAGE`, for an invalid usage or an empty `stdin`
- `65` is `EX_DATAERR`, for an invalid URL or invalid input
- `66` is `EX_NOINPUT`, for `language_unavailable` and for `no_captions`
- `69` is `EX_UNAVAILABLE`, for a provider that is unavailable or rate limited
- `70` is `EX_SOFTWARE`, reserved for defects that are ours
- `74` is `EX_IOERR`, for a local I/O failure
- `75` is `EX_TEMPFAIL`, for the operation timeout
- `76` is `EX_PROTOCOL`, for a malformed upstream answer
- `78` is `EX_CONFIG`, for a configuration error
- `130` means the run was cut short by `SIGINT`
- `141` means the reader of `stdout` closed the pipe
- `143` means the run was cut short by `SIGTERM`
- Under `--batch` the process exit is the code of the WORST item, never of the first one
## JSON Envelopes
### Success Envelope
- `--json` prints one JSON object on `stdout`
- The measured keys are `byte_size`, `content`, `delivered_language`, `duration_ms`, `format`, `language`, `provider`, `source_url`, `target_resolved`, `target_source` and `video_id`
- `language` echoes what you ASKED for, by design
- `delivered_language` is the only field that reports the track actually delivered
- `target_source` tells `argv` apart from `batch-file`
### Error Envelope
- A failure also prints one JSON object on `stdout`, never on `stderr`
- The discriminant is `error`, whose value is the boolean `true`
- There is NO nested `.error` object and there is NO `ok` field, so a filter like `.error.kind` fails against a boolean
- The top-level fields are `error`, `code`, `message`, `kind`, `retryable`, `provider`, `video_id`, `target_resolved`, `target_source`, `requested_language`, `available_languages` and `attempts`
- `message` is localised by `--ui-lang` and must NEVER be parsed
- Branch on `kind`, which is a stable English identifier for the failure class
- Branch on `retryable` to decide whether repeating the same call can help
```bash
## Integration Patterns
- Pipe the plain body straight into a file, because `stdout` carries nothing else
- Branch on the exit code first, and only then parse `stdout`
- Shape the envelope in the CLI instead of post-processing it, so an agent pays for fewer tokens
- Cap the payload with `--truncate-content` and `--max-output-bytes` when a transcript would blow your context
- Use `--offline` in a sandbox that must never open a socket
- Use `--no-input` in a non-interactive runner, so a missing URL fails fast instead of hanging on `stdin`
- Use `--resume` to make a long batch restartable
```bash
# Only the fields an agent needs
youtube-legend-cli --json --select video_id,language,byte_size "https://youtu.be/dQw4w9WgXcQ"
# A batch of URLs, four at a time, restartable
youtube-legend-cli --batch --jobs 4 --resume --json < urls.txt > out.ndjson
# Branch on the exit code, then on kind
if ! out=$(youtube-legend-cli --json "$url"); then
printf '%s\n' "$out" | jaq -r '.kind'
fi
# Never open a socket
youtube-legend-cli --offline --json "https://youtu.be/dQw4w9WgXcQ"
```
## Performance
- Three micro-benchmarks live in `benches/cache_bench.rs`
- `cache_path` measures composing the cache file path
- `srt_to_text` measures converting a SubRip body into plain text
- `noteey_to_text` measures converting a provider transcript body into plain text
- Run them with `cargo bench --bench cache_bench`
- No timing figure is published here, because this repository carries no reproducible baseline to cite
- The default URL length ceiling is 2048 characters, declared in `src/cli/mod.rs`
- The default whole-operation ceiling is 300 seconds, and it wraps the entire provider chain and every retry
## Troubleshooting FAQ
### The command exits 66 and prints nothing on stdout
- Exit 66 is `EX_NOINPUT`, which covers `no_captions` and `language_unavailable`
- Run again with `--json` and read `available_languages` to see what the video really offers
- Ask for one of those languages with `--lang`
### The command exits 69
- Exit 69 is `EX_UNAVAILABLE`, which covers a provider that is down or rate limited
- `provider-noiz` carries a five-per-day quota, so a quiet day is not the same as a broken install
- `--offline` also refuses the network by design, so check whether you passed it
### The command exits 2 instead of 64
- Exit 2 means `clap` refused the command line before the run started
- Read the message on `stderr`, fix the flag spelling, and try again
### My JSON filter on .error.kind returns nothing
- `error` is a boolean, not an object, so you cannot index it
- Read `.kind` at the top level instead
### Setting RUST_LOG changes nothing
- The binary reads NO environment variable
- Use `--log-level` on the command line, or set `log_level` through `config set`
### The run hangs with no URL
- Without a URL the CLI waits on `stdin` by design
- Pass `--no-input` to fail fast instead
### A batch run returned a failure exit even though most URLs worked
- Under `--batch` the process exit is the code of the worst item
- Read the per-item envelopes on `stdout` to see which ones failed
## Contributing
- Read [CONTRIBUTING.md](CONTRIBUTING.md) for the development workflow, the MSRV expectation, the style rules and the `no Co-authored-by` policy
- This project has NO CI, so you run the gates locally before you claim a change is green
## Security
- Read [SECURITY.md](SECURITY.md) for the supported versions, the threat model and the private disclosure channel
- The `secret_endpoints` module is declared `pub(crate)`, so upstream hostnames never enter the published rustdoc
## Changelog
- Every release is recorded in [CHANGELOG.md](CHANGELOG.md)
- The Portuguese mirror is [CHANGELOG.pt-BR.md](CHANGELOG.pt-BR.md)
- No version history is inlined in this README, on purpose
## License
- Dual-licensed under either [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option