Skip to content

zephinzer/ytsrtgen

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

11 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ytsrtgen

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.

Quick start

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-privileges

Then 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 .srt

To 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 step

See Makefile for all targets and variables.

How it works

  1. Client POSTs {"data":"<youtube-url>"} to /.
  2. 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>
    
  3. The resulting *.srt file is read from the temp dir.
  4. 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.

HTTP API

POST /

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.

GET /swagger

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.

Example

curl -s -X POST http://localhost:8080/ \
  -H 'Content-Type: application/json' \
  -d '{"data":"https://www.youtube.com/watch?v=dQw4w9WgXcQ"}' \
  | jq -r .srt

Configuration

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

Running locally

Requires Go 1.25+ and yt-dlp on PATH.

make build-go      # outputs ./bin/ytsrtgen
./bin/ytsrtgen

Or:

go run .

Testing

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 .srt500 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.

Running in Docker

docker build -t ytsrtgen .
docker run --rm -p 8080:8080 ytsrtgen

Recommended 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 \
  ytsrtgen

Deploying with Helm

A 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.

Quick deploy with the Makefile

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 ./dist

Plain helm invocation

If 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 --wait

Makefile

make help lists every target. Variables can be overridden on the command line, e.g. make publish TAG=v0.1.0 REGISTRY=ghcr.io.

Variables

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.

Targets

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)

Typical flows

Local development loop:

make build && make run

Cut a release:

git tag v0.1.0
make login
make publish               # multi-arch, tagged v0.1.0 and latest

One-off image to a different registry:

make publish REGISTRY=ghcr.io NAMESPACE=youruser TAG=v0.1.0

For Kubernetes deploy flows, see Deploying with Helm.

Image layout / hardening

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/mod and /root/.cache/go-build speed 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:app with /sbin/nologin.
  • yt-dlp and bgutil-ytdlp-pot-provider are installed into /opt/venv as the app user — no root-owned site-packages.
  • PATH is prefixed with /opt/venv/bin so the Go binary's exec.Command("yt-dlp", ...) resolves.
  • The Go binary is copied in root:root mode 0555 — executable by app, but app cannot overwrite it.
  • tini is PID 1, so SIGTERM is forwarded to the server and reaped yt-dlp subprocesses don't become zombies.
  • TMPDIR=/tmp is set explicitly so --read-only containers work when /tmp is a tmpfs mount.

Project layout

.
├── .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)

Dependencies

Caveats and known limitations

  • English auto-subs only. --sub-lang en is hard-coded. Videos without English auto-captions will return no srt produced.
  • Synchronous. Each request blocks until yt-dlp finishes. There is no queueing or rate limiting; put a reverse proxy in front if exposing publicly.
  • No request-size limit. json.NewDecoder reads the full body. Add http.MaxBytesReader if you expect untrusted clients.
  • data is passed directly to yt-dlp as a CLI argument. This is safe against shell injection (no shell is invoked — exec.Command is argv-style), but yt-dlp itself 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 .srt files, only the first match from filepath.Glob is returned.
  • No /health endpoint. Liveness/readiness must be inferred from the listener accepting connections.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors