A tiny Go HTTP service that fetches YouTube auto-generated subtitles as SRT via yt-dlp and returns them in the HTTP response body. This exists as a service for n8n to call.
Build and run the hardened Docker image with the supplied Makefile:
make build # build the image, tagged :<YYYYMMDD>-<sha8> and :latest
make run # run it on :8080 with --read-only, --cap-drop=ALL, no-new-privilegesThen in another shell:
curl -s -X POST http://localhost:8080/ \
-H 'Content-Type: application/json' \
-d '{"data":"https://www.youtube.com/watch?v=dQw4w9WgXcQ"}' \
| jq -r .srtTo publish a multi-arch image to docker.io/aijutsudev/ytsrtgen:
make login # prompts for credentials (or use $CR_PAT for ghcr.io)
make publish # buildx multi-arch build + push in one stepSee Makefile for all targets and variables.
- Client
POSTs{"data":"<youtube-url>"}to/. - The server creates a fresh temp directory and invokes:
yt-dlp --skip-download --write-auto-subs --sub-lang en \ --sub-format srt --convert-subs srt \ -o "%(id)s.%(ext)s" <youtube-url> - The resulting
*.srtfile is read from the temp dir. - The server responds with
{"srt":"<srt-data>"}and removes the temp dir.
exec.CommandContext ties yt-dlp's lifetime to the inbound request, so a client disconnect cancels the download.
Request body
{ "data": "https://www.youtube.com/watch?v=dQw4w9WgXcQ" }| Field | Type | Required | Notes |
|---|---|---|---|
data |
string | yes | A URL that yt-dlp accepts (full URL form) |
Success — 200 OK
{ "srt": "1\n00:00:00,000 --> 00:00:02,000\nHello\n..." }Errors
| Status | Body | When |
|---|---|---|
| 400 | {"error":"invalid json: ..."} |
Body is not valid JSON |
| 400 | {"error":"missing 'data' field"} |
data is empty or absent |
| 500 | {"error":"yt-dlp failed: ..."} |
yt-dlp exited non-zero (output included) |
| 500 | {"error":"no srt produced; ..."} |
yt-dlp succeeded but no .srt was written |
| 500 | {"error":"mktemp: ..."} etc. |
Filesystem / read failures |
Only POST is routed; other methods on / return 405.
Interactive OpenAPI 2.0 documentation, rendered by swaggo/http-swagger from annotations in main.go.
| Path | Returns |
|---|---|
/swagger |
302 redirect to /swagger/index.html |
/swagger/index.html |
Swagger UI |
/swagger/doc.json |
Raw OpenAPI spec (also available as swagger.json / swagger.yaml in ./docs) |
The spec is generated by swag into ./docs and embedded in the binary via a blank import. To regenerate after changing annotations:
make swagger # installs swag if missing, then writes ./docs/{docs.go,swagger.json,swagger.yaml}The build-go, build, buildx, and publish targets depend on swagger, so a fresh checkout that runs make publish will regenerate the docs before building the image.
curl -s -X POST http://localhost:8080/ \
-H 'Content-Type: application/json' \
-d '{"data":"https://www.youtube.com/watch?v=dQw4w9WgXcQ"}' \
| jq -r .srt| Env var | Default | Purpose |
|---|---|---|
LISTEN_ADDR |
:8080 |
Listener address passed to http.ListenAndServe |
TMPDIR |
OS default (/tmp in container) |
Where os.MkdirTemp places per-request work dirs |
Requires Go 1.25+ and yt-dlp on PATH.
make build-go # outputs ./bin/ytsrtgen
./bin/ytsrtgenOr:
go run .Run the unit tests:
make test # or: go test ./...Tests live in main_test.go and exercise handleGenerate end-to-end through httptest. They do not require yt-dlp or network access — instead, each test that needs to invoke yt-dlp writes a short shell script named yt-dlp into a t.TempDir() and prepends that directory to PATH via t.Setenv. Because the handler sets cmd.Dir to its own per-request temp dir, the fake script's $PWD is exactly where the handler will later filepath.Glob("*.srt"), so it can drop a fake .srt there to simulate success, exit non-zero to simulate failure, or write nothing to simulate "no subs available".
Coverage:
| Test | Scenario |
|---|---|
TestHandleGenerate_InvalidJSON |
Malformed request body → 400 invalid json |
TestHandleGenerate_MissingData |
data field empty/absent → 400 missing 'data' field |
TestHandleGenerate_Success |
Fake yt-dlp writes an .srt; handler returns it in {"srt": ...} |
TestHandleGenerate_YtDlpFails |
Fake yt-dlp exits non-zero; stderr is included in the 500 body |
TestHandleGenerate_NoSrtProduced |
Fake yt-dlp exits 0 but writes no .srt → 500 no srt produced |
TestHandleGenerate_PassesURLToYtDlp |
Verifies the request URL is the final argv element and core flags are present |
The test file carries //go:build !windows because the fake binary is a #!/bin/sh script.
docker build -t ytsrtgen .
docker run --rm -p 8080:8080 ytsrtgenRecommended defense-in-depth flags (the image is built to support all of these):
docker run --rm -p 8080:8080 \
--read-only --tmpfs /tmp \
--cap-drop=ALL \
--security-opt=no-new-privileges \
ytsrtgenA minimal Helm chart lives at charts/ytsrtgen/. It ships a single-replica Deployment and a ClusterIP Service, and intentionally exposes only three groups of values:
| Key | Default | Purpose |
|---|---|---|
image.repository |
ytsrtgen |
Image name |
image.tag |
latest |
Image tag |
image.pullPolicy |
IfNotPresent |
imagePullPolicy |
ports.containerPort |
8080 |
Container listener (also wires LISTEN_ADDR) |
ports.servicePort |
80 |
Service port |
resources.requests.cpu |
100m |
|
resources.requests.memory |
128Mi |
|
resources.limits.cpu |
500m |
|
resources.limits.memory |
512Mi |
Anything else (replica count, service type, ingress, RBAC, etc.) is intentionally hardcoded — fork the chart or post-process with helm template | kubectl apply -f - if you need more.
The helm-* targets set image.repository / image.tag from $(REGISTRY)/$(NAMESPACE)/$(IMAGE):$(TAG) automatically, so a normal install is one command after make publish:
make publish # build & push the image
make helm-deploy # helm upgrade --install into $(HELM_NAMESPACE)Other common flows:
make helm-lint # validate the chart
make helm-template # render to stdout
make helm-deploy KUBE_CONTEXT=prod HELM_NAMESPACE=apps # target a specific context/namespace
make helm-deploy HELM_VALUES=./my-values.yaml # override resources/ports/image
make helm-diff # diff vs. live release (needs helm-diff plugin)
make helm-uninstall # remove the release
make helm-package # tgz to ./distIf you'd rather skip the Makefile:
helm upgrade --install ytsrtgen ./charts/ytsrtgen \
--namespace ytsrtgen --create-namespace \
--set image.repository=docker.io/aijutsudev/ytsrtgen \
--set image.tag=20260518-abcdef12 \
--atomic --waitmake help lists every target. Variables can be overridden on the command line, e.g. make publish TAG=v0.1.0 REGISTRY=ghcr.io.
| Variable | Default | Notes |
|---|---|---|
REGISTRY |
docker.io |
Container registry host |
NAMESPACE |
aijutsudev |
Org / user namespace under the registry |
IMAGE |
ytsrtgen |
Image name |
TAG |
<YYYYMMDD>-<first 8 chars of HEAD sha> (UTC date), or <YYYYMMDD>-dev if not in a git repo |
Tag for the versioned image; :latest is always also pushed/built |
BUILD_DATE |
date -u +%Y%m%d |
UTC date portion of the default TAG |
COMMIT_SHA |
git rev-parse --verify HEAD | cut -c1-8 |
Commit portion of the default TAG (empty if HEAD is not a valid commit) |
PLATFORMS |
linux/amd64,linux/arm64 |
Comma-separated buildx platforms |
PORT |
8080 |
Host port for make run |
CR_PAT |
(unset) | If set and REGISTRY=ghcr.io, used by make login for non-interactive auth |
HELM_CHART |
./charts/ytsrtgen |
Path to the Helm chart |
HELM_RELEASE |
ytsrtgen |
Helm release name |
HELM_NAMESPACE |
ytsrtgen |
Target namespace (auto-created on helm-deploy) |
HELM_VALUES |
(unset) | Optional values file passed as -f $(HELM_VALUES) |
KUBE_CONTEXT |
(unset) | Optional kubectl/helm context (--kube-context) |
Resolved image ref: $(REGISTRY)/$(NAMESPACE)/$(IMAGE):$(TAG). Run make print to see what would be built. The Helm targets feed this ref into the chart via --set image.repository --set image.tag, so every helm-* invocation deploys whatever the current TAG would build.
Go (host)
| Target | Purpose |
|---|---|
tidy |
go mod tidy |
swagger |
Regenerate ./docs from swaggo annotations; installs swag into $(go env GOPATH)/bin if missing |
build-go |
Static host binary at ./bin/ytsrtgen (same flags as the image); depends on swagger |
test |
go test ./... |
Docker — single-arch, local daemon
| Target | Purpose |
|---|---|
build |
docker build for the local arch, tags :$(TAG) and :latest; depends on swagger so ./docs is regenerated before the build context is sent |
run |
Runs the image on $(PORT) with --read-only --tmpfs /tmp --cap-drop=ALL --security-opt=no-new-privileges |
shell |
Drops you into /bin/sh inside a fresh container for debugging |
Docker — multi-arch via buildx
| Target | Purpose |
|---|---|
buildx-setup |
Creates and activates a buildx builder named ytsrtgen (idempotent) |
buildx |
Multi-arch build for $(PLATFORMS) without pushing (cached layers only); depends on swagger |
publish |
Multi-arch build and push in one buildx invocation (canonical way to ship a manifest list); depends on swagger |
Publishing
| Target | Purpose |
|---|---|
login |
docker login $(REGISTRY). If REGISTRY=ghcr.io and $CR_PAT is set, logs in non-interactively |
push |
Pushes the locally-built :$(TAG) and :latest tags (single-arch; use publish for multi-arch) |
Helm
| Target | Purpose |
|---|---|
helm-lint |
helm lint the chart with the resolved image ref applied |
helm-template |
Render the chart locally (no cluster contact); useful for diffing or piping into kubectl apply |
helm-diff |
Show what would change vs. the live release (requires the helm-diff plugin) |
helm-deploy |
helm upgrade --install against the current cluster with --atomic --wait |
helm-uninstall |
helm uninstall the release |
helm-package |
Package the chart into ./dist |
Housekeeping
| Target | Purpose |
|---|---|
print |
Prints resolved image refs, PLATFORMS, and Helm release info |
clean |
Removes ./bin, ./dist, and the local image tags |
help |
Lists targets (default goal) |
Local development loop:
make build && make runCut a release:
git tag v0.1.0
make login
make publish # multi-arch, tagged v0.1.0 and latestOne-off image to a different registry:
make publish REGISTRY=ghcr.io NAMESPACE=youruser TAG=v0.1.0For Kubernetes deploy flows, see Deploying with Helm.
The Dockerfile is a two-stage build:
Stage 1 — golang:1.25-alpine (build)
CGO_ENABLED=0,GOFLAGS=-mod=readonly— fully static binary, no on-the-fly module mutation.-trimpath -ldflags="-s -w"— strips build host paths and debug symbols.- BuildKit cache mounts for
/go/pkg/modand/root/.cache/go-buildspeed up rebuilds without bloating the image.
Stage 2 — alpine:3.22.4 (runtime)
- Installs
python3,py3-pip,ca-certificates,tini. - Creates a non-root system user
app:appwith/sbin/nologin. yt-dlpandbgutil-ytdlp-pot-providerare installed into/opt/venvas theappuser — no root-owned site-packages.PATHis prefixed with/opt/venv/binso the Go binary'sexec.Command("yt-dlp", ...)resolves.- The Go binary is copied in
root:rootmode0555— executable byapp, butappcannot overwrite it. tiniis PID 1, so SIGTERM is forwarded to the server and reaped yt-dlp subprocesses don't become zombies.TMPDIR=/tmpis set explicitly so--read-onlycontainers work when/tmpis a tmpfs mount.
.
├── .dockerignore # keeps the docker build context small
├── .gitignore # ignores ./bin and vendor/
├── Dockerfile # multi-stage hardened build
├── Makefile # build / run / publish / helm recipes
├── README.md
├── charts/
│ └── ytsrtgen/ # minimal Helm chart (Deployment + ClusterIP Service)
├── docs/ # swaggo-generated OpenAPI spec (docs.go, swagger.json, swagger.yaml); regenerate with `make swagger`
├── go.mod # module zephinzer/ytsrtgen, Go 1.25.5
├── go.sum
├── main.go # entire server (single file) + swaggo annotations
└── main_test.go # handler tests; fakes yt-dlp via PATH (see Testing)
github.com/gorilla/muxv1.8.1 — HTTP routing.github.com/swaggo/http-swagger— serves Swagger UI at/swagger.github.com/swaggo/swag— build-time generator for./docsfrom annotations inmain.go(theswagCLI is installed on demand bymake swagger).yt-dlp— installed in the runtime image's venv.bgutil-ytdlp-pot-provider— yt-dlp plugin for PO Token provisioning (mitigates YouTube's bot-check throttling).
- English auto-subs only.
--sub-lang enis hard-coded. Videos without English auto-captions will returnno srt produced. - Synchronous. Each request blocks until
yt-dlpfinishes. There is no queueing or rate limiting; put a reverse proxy in front if exposing publicly. - No request-size limit.
json.NewDecoderreads the full body. Addhttp.MaxBytesReaderif you expect untrusted clients. datais passed directly toyt-dlpas a CLI argument. This is safe against shell injection (no shell is invoked —exec.Commandisargv-style), butyt-dlpitself accepts many URL forms, including playlists and non-YouTube sites. Validate the URL upstream if you need to restrict that.- Single SRT. If yt-dlp emits multiple
.srtfiles, only the first match fromfilepath.Globis returned. - No
/healthendpoint. Liveness/readiness must be inferred from the listener accepting connections.