- TypeScript 49.7%
- Go 48%
- Dockerfile 1%
- CSS 0.9%
- Shell 0.2%
- Other 0.2%
| dev/media | ||
| server | ||
| webapp | ||
| .gitignore | ||
| .gitlab-ci.yml | ||
| .tool-versions | ||
| AGENTS.md | ||
| compose.dev.yaml | ||
| compose.yaml | ||
| README.md | ||
| Taskfile.yml | ||
Subalineado
Go API and SolidJS + TypeScript webapp in one repository. Subtitle metadata is stored locally in CloverDB with a pure-Go Badger backend; no database server is required.
Start in development
Install Docker with Compose 2.32+ and Task v3. Task is a standalone binary. With Node installed, use npm install --global @go-task/cli; other installation options are linked above. Then run:
task dev
Open http://localhost:5173. The API is at http://localhost:8080.
If a port is occupied, prefix the command with API_PORT=18080 DEV_WEB_PORT=15173 (or WEB_PORT=13000 for production).
Ctrl+C stops both services; remove their containers with:
task dev:down
Compose Watch syncs webapp source into Vite for hot reload and rebuilds the Go service on changes. Dependency and Vite configuration changes rebuild the webapp. Go module and compilation caches persist through BuildKit cache mounts. After a broken Go build, fix and save the source to trigger another build. Dockerfile changes require restarting with --build.
Discover and extend commands
task # List commands with descriptions
task --list # Same list, explicitly
task --summary dev # Read usage details for development
task dev:status # See running services
task dev:logs # Follow their logs
task install # Install native Go and webapp dependencies
task check # Go vet, Biome and TypeScript checks
task test # Go race tests and Vitest tests
task format # Go formatting and safe Biome fixes
task build # Build both production Docker images
task webapp:client # Regenerate the typed API client from the OpenAPI spec
install, check, test, and format use your native Go/Node/pnpm tools. dev, dev:*, and build use Docker. Individual commands such as task server:test and task webapp:check are also listed. Tasks stop when a command fails.
All commands live in the root Taskfile.yml. Add a named task with desc and cmds; use dir for commands in a service directory and summary for detailed usage. Task finds the file from repository subdirectories too.
webapp:build:
desc: Build the webapp locally
dir: webapp
cmds:
- pnpm build
Compose remains the source of truth for services and watch rules. Task supplies short, discoverable command names. The underlying development command is still available:
docker compose -f compose.yaml -f compose.dev.yaml up --build --watch
Run production images locally
docker compose up --build -d --wait
Open http://localhost:3000. Nginx serves the compiled webapp and proxies /api/* and /health to Go. Both runtime containers run as non-root users and expose health checks. Ports bind to loopback for local use.
docker compose down
Native development and checks
Use Node 24, pnpm 10.28.2, and Go 1.27.2. .tool-versions pins the native Node and Go versions for asdf; the Go module also requires Go 1.27.2. Install pnpm with npm install --global pnpm@10.28.2.
The API currently targets Linux and requires /proc access, alass-cli 2.0.0, ffmpeg and ffprobe on PATH. Install alass with cargo install alass-cli --version 2.0.0 --locked (Rust and a C compiler are needed to build it). Docker packages these dependencies automatically; the Go API itself is built with CGO_ENABLED=0.
In one terminal:
cd server
go mod download
MEDIA_ROOT=../dev/media DB_PATH=../dev/db go run ./cmd/api
In another:
cd webapp
pnpm install --frozen-lockfile
pnpm dev
Native Go development requires restarting go run after edits; Compose Watch automates this in the container workflow.
Server checks:
cd server
go test -race ./...
go vet ./...
go build -o bin/api ./cmd/api
Webapp checks:
cd webapp
pnpm check
pnpm test
pnpm build
pnpm format applies Biome formatting and safe fixes; pnpm test:watch runs Vitest interactively.
Continuous integration
.gitlab-ci.yml runs two jobs in parallel on every push and merge request:
- Server:
go vet ./...andgo test -race ./...with Go 1.27.2. - Webapp:
pnpm check(Biome and TypeScript) andpnpm test(Vitest) with Node 24 and pnpm 10.28.2. Dependencies use the frozen lockfile.
When a branch has an open merge request, its pushes run the merge request pipeline instead of a duplicate branch pipeline. All checks run even for documentation-only changes. A GitLab runner supporting container images (Docker or Kubernetes executor) must be available to the project; no deployment credentials are needed.
Structure and API
server/cmd/api: process startup, HTTP timeouts, signal handling and graceful shutdown.server/cmd/openapi: writes the OpenAPI spec to a file without starting a listener, for client generation.server/internal/httpapi: router, Huma-registered HTTP operations, OpenAPI spec/docs and endpoint tests.server/internal/fstools: filesystem helpers, e.g.os.Root-scoped access.server/internal/storage: Clover metadata repository and Badger v4 storage adapter.server/internal/subtitles: original backups, alass execution, atomic replacement and interrupted-operation recovery.webapp/ui/src: routed pages (/links to sections,/navigatebrowses the media root), thin wrapper over the API client and component tests.webapp/api-client:@subalineado/api, the hey-api client generated from the server OpenAPI spec (task webapp:clientregenerates it; commit the result).Taskfile.yml: discoverable development, dependency, formatting and verification commands.compose.yaml: production images and service dependencies.compose.dev.yaml: development overrides and file-watch rules.
| Request | Response |
|---|---|
GET /health |
200 {"status":"ok"} |
GET /api/test |
200 {"message":"API connection successful"} |
GET /api/files?path={dir} |
200 {"path":"...","entries":[{"name":"...","type":"directory|video|subtitle"}]} |
POST /api/subtitles/sync |
200 {"id":"...","subtitlePath":"...","videoPath":"...","syncedWithAlass":true} |
POST /api/subtitles/revert |
200 {"id":"...","subtitlePath":"...","syncedWithAlass":false} |
GET /api/openapi.json |
200 OpenAPI 3.1 spec (.yaml and -3.0 variants also served) |
GET /api/docs |
200 interactive API documentation |
Responses use application/json; errors use RFC 9457 application/problem+json ({"title","status","detail"}). Other methods return 405; unknown routes return 404. The OpenAPI spec is generated from the registered operations. The webapp consumes it through a hey-api client generated by task webapp:client; other clients can be built from /api/openapi.json with tools like openapi-generator or openapi-typescript. HTTP_ADDR configures the API listener (default :8080). MEDIA_ROOT (required; the server exits with an error when unset) sets the directory holding videos and subtitles; the dev Compose setup points it at ./dev/media, whose contents are gitignored; GET /api/files lists its folders, videos (.mkv, .mp4) and subtitles (.srt, .vtt, .ass, .ssa), skipping hidden files and the reserved subalineado-data folder. Filesystem access is confined to the media root via os.OpenRoot: paths or symlinks escaping it return 400, and unknown directories return 404. Vite reads API_PROXY_TARGET (default http://localhost:8080); Compose sets it to the server service. This is a server-side proxy setting, not a browser URL.
Synchronize subtitles
DB_PATH is required and must point to a writable directory, not a single database file. Clover uses Badger v4 explicitly, with synchronous writes. Only one API process may use a database directory at a time. Keep this directory private and outside the media root; back up both the database and subalineado-data together. Compose stores the database at /data/db in its persistent server-data volume. Do not remove that volume if you want to retain subtitle identities and backup references.
curl --fail-with-body http://localhost:8080/api/subtitles/sync \
-H 'Content-Type: application/json' \
-d '{"subtitlePath":"Movies/Example.en.srt","videoPath":"Movies/Example.mkv"}'
Both paths are relative to MEDIA_ROOT. Synchronization accepts .srt, .ass and .ssa, and references .mkv or .mp4 videos. VTT remains browsable but synchronization returns 422. Absolute paths, symlinks, non-regular files and internal data paths are rejected. The request waits up to five minutes for processing; server and Nginx response timeouts are six minutes. API clients should allow more than five minutes too.
Before alass runs, the API creates a subtitle document with syncedWithAlass=false and durably copies the first original to subalineado-data/<id>-original.<extension>. Alass writes subalineado-data/<id>-alass.<extension>, which is validated and renamed over the source, preserving its permission bits. The generated output path disappears after the rename. The original backup is never overwritten; repeated requests reuse the ID and synchronize from that first original, not the currently installed subtitle. Missing or modified originals block further synchronization rather than being silently recreated.
Failures before replacement leave the current subtitle untouched. Concurrent requests for the same normalized path or detected external edits return 409; missing files return 404, alass failures 502, and processing timeouts 504. Filesystem/database failures return 500. Replacement and database updates cannot form one transaction: a durable replacement intent and output hash allow reconciliation on restart or retry if recording completion fails after rename.
Revert to the original
curl --fail-with-body http://localhost:8080/api/subtitles/revert \
-H 'Content-Type: application/json' \
-d '{"subtitlePath":"Movies/Example.en.srt"}'
Revert copies the verified original backup to a temporary file and atomically renames it over the subtitle, preserving the installed file's permission bits. It does not call alass or require the video. The subtitle ID and original backup are retained, syncedWithAlass becomes false, and subsequent synchronization still uses the same original. Repeated revert requests are safe. The subtitle, its database record and its original backup must exist; otherwise the endpoint returns 404. Modified backups are rejected with 500, leaving the subtitle untouched. Path restrictions and the per-subtitle lock are shared with synchronization (400 for invalid paths, 409 for concurrent sync/revert or detected edits). Interrupted restores use the same durable intent/recovery protocol as synchronization.
The source subtitle and subalineado-data must be on the same filesystem; cross-filesystem rename fails safely, without a copy-and-delete fallback. The media directories must be writable by the API user (UID/GID 1000:1000 in Docker and Compose), including permission to replace subtitle directory entries. Compose binds ${MEDIA_ROOT:-./dev/media} to /media; the development override always uses ./dev/media. Do not allow untrusted local processes to modify the database or internal data directory. External editors should not modify subtitles during synchronization: content/identity checks catch changes before replacement, but cannot lock out unrelated filesystem writers.
If upgrading from the previous UID 10001 image, update the existing database volume's ownership once, with the server stopped (the database contents are preserved):
docker compose -f compose.yaml -f compose.dev.yaml stop server
docker compose -f compose.yaml -f compose.dev.yaml run --rm --no-deps --user 0:0 --entrypoint chown server -R 1000:1000 /data
task dev
Existing subalineado-data backup directories created by UID 10001 also need ownership adjusted to UID/GID 1000:1000. New volumes and backup directories already use the new user.
Run deterministic workflow, HTTP and race tests with task server:test. An optional real-tool smoke test generates speech using espeak:
cd server
ALASS_INTEGRATION=1 go test ./internal/subtitles -run TestRealAlass -v
Tooling choices
Chi keeps routing and middleware close to standard net/http handlers. Huma registers typed operations on the Chi router and derives the OpenAPI 3.1 spec, interactive docs and input validation from Go structs and struct tags. Add endpoints with huma.Register(...); the stable OperationIDs become method names in generated clients, and endpoint tests exercise the same routes through the plain http.Handler.
Vite provides SolidJS compilation, development hot reload and production builds. Biome handles formatting and linting; TypeScript checks types; Vitest and Solid Testing Library verify rendered pages and navigation flows. @solidjs/router keeps the current folder in the ?path query string, so folder views are shareable links and the back button works. TanStack Query manages server state: each folder listing is cached by path, so revisiting folders does not hit the API again. hey-api generates the typed API client in webapp/api-client from the server OpenAPI spec, so endpoint changes in Go flow into the webapp as compile-time types. The webapp talks to the client through the thin wrapper in webapp/ui/src/api.ts, which centralizes the base URL, timeouts and error messages.
Task provides language-independent commands in YAML, with descriptions via task --list and detailed help via task --summary. Compose Watch manages service startup and reloads underneath task dev.
Alternatives considered: Just is a concise command runner with Make-inspired syntax; mise offers TOML tasks alongside runtime and environment management. Task fits the declarative YAML style already used by Compose and leaves the existing asdf runtime setup in place.
The Docker images use explicit Go and Node major/version selections, frozen pnpm dependencies and a Go checksum file. Runtime base tags receive upstream updates; pin image digests if reproducible deployment images become a requirement.