- TypeScript 54.6%
- Go 41.9%
- Dockerfile 1.8%
- HTML 0.8%
- JavaScript 0.7%
- Other 0.2%
| .forgejo/workflows | ||
| .github/workflows | ||
| backend | ||
| frontend | ||
| .gitignore | ||
| Dockerfile | ||
| overview.png | ||
| README.md | ||
| video.png | ||
Shuoshuo
A self-hosted video transcription web app. Upload a video, and it gets processed in the background into word-level subtitles (with pinyin and translation) that can be viewed alongside the video in the browser.
Screenshots
| Video list | Video player with subtitles |
|---|---|
![]() |
![]() |
Quick start (Docker)
The easiest way to run Shuoshuo is with the prebuilt Docker image, which
bundles the backend, the frontend, ffmpeg, and zhpipe-whisper:
docker run -d -p 3333:3333 -v shuoshuo-data:/data --name shuoshuo cybertim/shuoshuo
Then open http://localhost:3333 and upload a video.
The data directory (/data) holds the SQLite DB, uploads, and whisper models
(WHISPER_MODEL_CACHE_PATH), so the shuoshuo-data volume persists them
across container restarts.
The image is built and pushed to Docker Hub automatically when a release is published. The first transcription downloads the Whisper model (
large-v3-turboby default) into the volume, which can take a while.
How it works
- A video is uploaded through the web UI (
POST /api/videos) and stored on disk with statusqueued. - A background queue worker polls for queued videos, extracts audio with
ffmpeg, and transcribes it with zhpipe-whisper (Whisper,large-v3-turboby default). - The transcription is saved as JSON next to the video file and the video status
becomes
ready. - The web UI lists ready videos and plays them with the subtitles in sync.
Stack
| Layer | Technology |
|---|---|
| Backend | Go 1.25, net/http (std ServeMux), GORM + gormlite (pure-Go SQLite) |
| Frontend | React 18 + TypeScript, Vite, Mantine UI, @tanstack/react-query, react-router |
| Transcription | ffmpeg + zhpipe-whisper (external binaries, required at runtime) |
Project layout
shuoshuo/
├── backend/
│ ├── cmd/server/
│ │ ├── main.go # entry point: flags, HTTP mux, SPA serving, graceful shutdown
│ │ └── route/ # HTTP handlers + request/response types
│ ├── internal/
│ │ ├── persist/ # GORM models + repository pattern (Video)
│ │ └── queue/ # background worker: ffmpeg -> zhpipe-whisper
│ ├── go.mod
│ └── go.sum
├── frontend/
│ ├── src/
│ │ ├── main.tsx # app bootstrap (Mantine, Router, React Query)
│ │ ├── main.css # global styles
│ │ ├── theme.ts # Mantine theme
│ │ ├── components/ # AppShell, VideoList, VideoPlayer
│ │ └── api/ # client.ts (fetch wrapper) + hooks.ts (React Query hooks)
│ ├── index.html
│ ├── package.json
│ └── vite.config.ts
├── .forgejo/workflows/flow.yml # Forgejo CI
├── .github/workflows/flow.yml # GitHub CI: build backend + frontend, push image to Docker Hub
└── Dockerfile
Prerequisites
- Go (see
backend/go.mod, currently 1.25) - Node.js 20+ (and
bunornpmfor the frontend) - ffmpeg on
PATH - zhpipe-whisper on
PATH
Development
Run the backend and frontend separately.
Backend
cd backend
go run ./cmd/server --port 8080 --data-dir ./data
Flags:
| Flag | Default | Description |
|---|---|---|
--port |
8080 |
HTTP listen port |
--data-dir |
./data |
Directory for the SQLite DB and uploaded files |
--www-dir |
(empty) | Frontend build directory; when set, the server serves the SPA alongside the API |
The SQLite DB and uploads/ directory are created automatically under --data-dir.
Frontend
cd frontend
bun install # or: npm install
bun run dev # or: npm run dev
The Vite dev server runs on http://localhost:3000 and proxies /api/* to
http://localhost:8080.
Running everything together (single port)
Build the frontend, then point the backend at it:
cd frontend && bun run build
cd ../backend && go run ./cmd/server --port 8080 --data-dir ./data --www-dir ../frontend/dist
The server then serves the SPA (with client-side routing fallback) and the API on the same port.
Configuration (transcription)
The transcription worker reads these environment variables:
| Variable | Default | Description |
|---|---|---|
WHISPER_MODEL |
base |
Whisper model name |
WHISPER_MODEL_CACHE_PATH |
~/.cache/zhpipe/models |
Where whisper models are cached |
API
| Method | Path | Description |
|---|---|---|
POST |
/api/videos |
Multipart upload (field video); returns the created video record |
GET |
/api/videos?status=ready |
List videos (status filter, defaults to ready) |
GET |
/api/videos/{id} |
Get a single video by ID |
GET |
/api/videos/serve/{filename} |
Stream the video file |
GET |
/api/subtitles/{filename} |
Get the subtitle JSON for a video |
Errors are returned as {"error": "...", "details": "..."}.
Build the Docker image yourself
The Dockerfile bundles the backend binary, the frontend build,
and zhpipe-whisper, and runs everything on port 3333. Build the backend and
frontend artifacts first (as the CI workflow does), then:
docker build -t shuoshuo .
docker run -p 3333:3333 -v shuoshuo-data:/data shuoshuo
CI
.github/workflows/flow.ymlbuilds the backend (Go) and frontend (Node), then builds and pushes a Docker image to Docker Hub. It runs when a release is published..forgejo/workflows/flow.ymlbuilds the backend and frontend, then builds a local image withpodman. It runs onworkflow_dispatch.

