- TypeScript 63.2%
- Vue 31.1%
- Astro 5.3%
- JavaScript 0.4%
Docs live in src/content/docs/api.md (Astro content collection); the page is a thin styled renderer. Linked from the nav and Settings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|---|---|---|
| migrations | ||
| src | ||
| test | ||
| .dev.vars.example | ||
| .gitignore | ||
| .nvmrc | ||
| astro.config.mjs | ||
| bun.lock | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
| wrangler.jsonc | ||
Subtitle Translator
A private, invite-only web app that translates .srt subtitle files with Gemini — built on
Astro + Vue, running serverless on Cloudflare (Workers, Workflows, D1, R2, Access).
How it works
- Upload an
.srtfile and pick a target language. - An AI pre-pass (AnalyzeWorkflow) reads the whole dialogue and builds a character sheet: movie title guess, characters with genders inferred from dialogue evidence, speech styles, tone, and a glossary of terms to translate consistently.
- You review and edit the sheet — genders drive grammatical agreement in gendered languages (Ukrainian «він з'їв» vs «вона з'їла»). Optionally search TMDB to confirm the movie and merge real cast genders into the sheet.
- Translation (TranslateWorkflow) runs as a durable background job: the file is split into chunks translated in concurrency-limited parallel waves. Every chunk call carries the shared character sheet + glossary and a tail of preceding source cues for local context, so chunks stay consistent and parallel. Safe to close the tab.
- Download the translated
.srt— timestamps, numbering, formatting tags, BOM and line endings are preserved byte-for-byte; only cue text changes. Files stay in your history for later download.
Failed chunks retry automatically with exponential backoff (free-tier friendly). If a job still fails, completed chunks are kept — "Start again" only re-translates what's missing. Editing the sheet or switching language invalidates the cache and re-translates fully.
Stack
- Astro 7 + Vue 3 (SSR) on Cloudflare Workers via
@astrojs/cloudflare; the custom worker entry (src/worker.ts) also exports bothWorkflowEntrypointclasses. - Cloudflare Workflows for durable analyze/translate jobs.
- D1 for jobs, per-chunk progress, and user settings; R2 for the SRT files.
- Cloudflare Access (Zero Trust) for auth — no registration UI; users are allowlisted
in the Cloudflare dashboard. The Worker verifies the Access JWT (
jose). - Gemini Interactions API via a thin fetch client with structured JSON output.
- Per-user settings: personal Gemini API key (AES-GCM-encrypted at rest) and translation concurrency, with shared-key defaults.
Local development
Prereqs: Bun, Node ≥ 22.15 (nvm use picks it up from .nvmrc).
bun install
cp .dev.vars.example .dev.vars # fill in keys; generate SETTINGS_ENC_KEY with: openssl rand -base64 32
bunx wrangler d1 migrations apply DB --local
bun run dev # http://localhost:4321 — D1/R2/Workflows run locally
Without Cloudflare Access locally, DEV_USER_EMAIL from .dev.vars is your identity.
bun run test # vitest (SRT parser, chunker)
bunx tsc --noEmit # type check
bun run preview # production build under wrangler dev (shares local state)
Deploying
bunx wrangler login
bunx wrangler d1 create subtitle-translator # put the returned id in wrangler.jsonc "database_id"
bunx wrangler r2 bucket create subtitle-translator-files
bunx wrangler secret put GEMINI_API_KEY # shared default key (free tier is fine)
bunx wrangler secret put TMDB_API_TOKEN # v3 api key or v4 read access token
bunx wrangler secret put SETTINGS_ENC_KEY # openssl rand -base64 32
bunx wrangler d1 migrations apply DB --remote
bun run deploy
Then lock it behind Cloudflare Access:
- Add a custom domain to the Worker (Workers → your worker → Domains & Routes) and disable the workers.dev route.
- In Zero Trust → Access → Applications, add a self-hosted app for that domain with an allowlist policy (the emails you invite).
- Copy your team domain (
<team>.cloudflareaccess.com) and the app's AUD tag intoACCESS_TEAM_DOMAIN/ACCESS_AUDin wrangler.jsonc, thenbun run deployagain.
Never set DEV_USER_EMAIL as a production secret — it is the local-dev auth bypass.
Notes:
- Workers Paid ($5/mo) is recommended for full movies: the free plan caps a workflow instance at 50 outbound requests (≈ 45 chunks ≈ 2,200 cues) and 10 ms CPU per step.
GEMINI_MODEL,TRANSLATE_CONCURRENCY,CHUNK_SIZE,CONTEXT_TAILare tunable inwrangler.jsonc(users can override key/concurrency per-account in Settings).
HTTP API (v1)
Programmatic access for external systems. Create an API key on the Settings page and
send it as Authorization: Bearer <key> to /api/v1/*. Keys are stored as SHA-256 hashes
(shown once at creation); jobs created with a key belong to the key's owner.
/api/v1 requires a Cloudflare Access bypass policy so machine traffic reaches the
Worker (Zero Trust → Access → Applications → add a self-hosted app for
<your-domain>/api/v1 with a single Bypass policy including Everyone). The Bearer
key check in the middleware is then the only gate on those paths.
BASE=https://subtitles.example.com/api/v1
AUTH="Authorization: Bearer st_..."
# 1. Upload — body is the raw .srt (≤ 2 MB). Returns {"id": "...", "status": "analyzing"}
JOB=$(curl -s -X POST -H "$AUTH" -H "content-type: application/octet-stream" \
--data-binary @movie.srt \
"$BASE/jobs?target_lang=Ukrainian&filename=Show.S01E02.srt" | jq -r .id)
# 2. Poll GET $BASE/jobs/$JOB until status is "ready" (includes character_sheet)
# 3. Optionally adjust the sheet: PUT $BASE/jobs/$JOB/sheet
# 4. Start translation:
curl -s -X POST -H "$AUTH" -H "content-type: application/json" -d '{}' \
"$BASE/jobs/$JOB/translate"
# 5. Poll until "done" (progress.done / progress.total = chunks), then:
curl -s -H "$AUTH" -o translated.srt "$BASE/jobs/$JOB/download?which=translated"
| Endpoint | Method | Notes |
|---|---|---|
/api/v1/jobs?target_lang=&filename= |
POST | Raw .srt body. Season/episode auto-parsed from filename. |
/api/v1/jobs |
GET | List your jobs. |
/api/v1/jobs/:id |
GET | Status, character sheet, chunk progress, failed chunks. |
/api/v1/jobs/:id/sheet |
PUT | Update sheet / title / season / episode before translating. |
/api/v1/jobs/:id/translate |
POST | Start or restart. Optional {"target_lang": "..."} — a new language re-translates fully; a retry after error reuses completed chunks. |
/api/v1/jobs/:id/download?which=translated|original |
GET | Streams the .srt. |
/api/v1/jobs/:id |
DELETE | Delete the job and its files. |
Job statuses: uploaded → analyzing → ready → translating → done | error.
This product uses the TMDB API but is not endorsed or certified by TMDB.