Skip to content

Backend

The backend is a Cargo workspace with two crates:

Crate Binary Purpose
crates/vistilla-api vistilla-api The server: pages, API, media, workers.
crates/vistilla-cli vistilla-cli A small command-line client that signs in and uploads files. See Command-line tools.

Both use Rust edition 2021. The release profile uses opt-level = 2, codegen-units = 16 and no LTO, to keep peak build memory low on small build machines.

Source layout (crates/vistilla-api/src)

Module Responsibility
main.rs Start-up, the passwd subcommand, AppState, graceful shutdown.
config.rs Reads every VISTILLA_* environment variable into Config.
db.rs Opens SQLite, applies the embedded migrations.
auth.rs Password hashing, sessions, the Auth / OptionalAuth extractors, user creation.
perms.rs The Role type (viewer < member < admin < owner) and its checks. The access rules themselves (team_access, video_access, video_for_edit) are in routes/mod.rs.
error.rs AppError, mapping errors to HTTP status and {"error": …}.
ids.rs Random ids and tokens, SHA-256 token hashing, slugs, now_ms().
crypto.rs Sealer: XChaCha20-Poly1305 encryption of stored AI keys.
mail.rs The designed HTML (and plain-text) email template.
delivery.rs Sending email, at once: SMTP, Brevo or the mail relay.
jobs.rs The background job worker (seek index, silences, transcripts, summaries, sweeps).
seekable.rs Rewrites recordings so they can be seeked, and applies trims (ffmpeg).
webm_index.rs Adds the index to a WebM too big for the disk, as it streams back to the store in parts.
silences.rs Finds long pauses for "skip silences" (ffmpeg).
web.rs Page routes, link-preview tags, /sw.js, /favicon.svg.
storage/ fs.rs (filesystem), s3.rs + sigv4.rs (S3-compatible).
ai/ Providers, audio handling, transcription, summaries. See AI pipeline.
routes/ One module per API area: auth_routes, recovery, teams, collections, videos, comments, shares, moments, intelligence, ai_settings, analytics, notifications.

Start-up sequence (main.rs)

  1. Set up logging with tracing-subscriber. The filter comes from VISTILLA_LOG (default info,sqlx=warn).
  2. Read the configuration from the environment.
  3. Connect to SQLite and apply any migrations not applied yet.
  4. Open the object store. For S3, make sure the bucket exists; a failure is only a warning.
  5. Load the encryption key (VISTILLA_SECRET_KEY, or a secret.key file it creates).
  6. If VISTILLA_RUN_WORKERS is on (the default):
    • put any job left running by a crash back to pending;
    • spawn the jobs worker.
  7. Build the router, wrap it in TraceLayer, bind to VISTILLA_BIND (default 0.0.0.0:8787) and serve.
  8. Shut down gracefully on Ctrl-C or SIGTERM.

AppState, shared by every handler, holds:

  • the SqlitePool;
  • the Storage;
  • the Config;
  • the Sealer.

Routing (routes/mod.rs)

The router is assembled from:

  • /api/*: the JSON API. See the HTTP API reference.
  • /media/*: video, thumbnail, caption and download responses. These sit outside /api so a <video src> can point straight at them.
  • /assets/*: ServeDir over VISTILLA_WEB_ROOT/assets, with Cache-Control: no-cache.
  • /docs/*: ServeDir over VISTILLA_DOCS_ROOT (this site), with /docs redirecting to /docs/.
  • Pages from web.rs.

A middleware adds security headers to every response. See Security.

Request size limits

Route Limit
PUT /api/videos/{id}/blob (whole-file upload) No body limit. Checked against VISTILLA_MAX_UPLOAD_BYTES while streaming.
PUT /api/videos/{id}/parts/{n} 64 MiB per part; all parts together up to VISTILLA_MAX_RECORDING_BYTES.
PUT /api/videos/{id}/thumbnail 8 MiB.
PUT /api/me/avatar 1 MiB at the router; the handler refuses over 512 KiB (413), and anything but JPEG, PNG or WebP.
Everything else axum's default (2 MB).

Errors

Handlers return AppResult<T>. AppError maps to HTTP like this:

Variant Status Body
BadRequest(msg) 400 {"error": msg}
Unauthorized 401 {"error": "authentication required"}
Forbidden(msg) 403 {"error": msg}
NotFound(what) 404 {"error": "<what> not found"}
Conflict(msg) 409 {"error": msg}
TooLarge 413 {"error": "payload too large"}
Database / Internal 500 {"error": "internal error"}. The real cause is only logged.

Background workers

Jobs worker (jobs.rs)

It loops forever and does one unit of work per tick, trying these in order:

  1. seekable::index_next: make a new recording seekable, and apply its trim.
  2. silences::detect_next: find long pauses.
  3. run_next_transcript
  4. run_next_summary
  5. sweep_abandoned_upload: discard live uploads with no new part for 7 days.
  6. backfill: for workspaces that have AI turned on, newest video first, queue a missing transcript, then a missing summary, then a summary that has no chapters yet.

When a tick finds nothing to do, the worker sleeps 5 seconds. Jobs are claimed with a single UPDATE … SET status='running' … WHERE video_id = (SELECT … LIMIT 1) RETURNING statement, so two workers can never take the same job. The HTTP client used for AI calls has a 600-second timeout. Jobs left running when the server stopped are put back to pending at start-up.

With S3 storage and ffmpeg, a transcript job does not download the video. ffmpeg reads it from the bucket over a signed link and keeps only its sound track (a few megabytes an hour), and the rest of the job works on that. The pause finder (silences.rs) reads from the link the same way. With folder storage, or without ffmpeg, the video is downloaded to a temporary file.

A transcript job whose video has no length (duration_ms 0) measures it first: from the file's header with ffprobe, or, for a file straight from MediaRecorder that has none, by reading it through with ffmpeg (-c copy -f null). Without it a long recording would be sent in one piece instead of in windows. Each job logs when it starts.

External tools

The runtime image installs ffmpeg (and its ffprobe). It is used to:

  • make recordings seekable and trim them;
  • detect silences;
  • measure loudness;
  • extract and re-encode audio for the AI.

Without ffmpeg most of Vistilla still works: recordings are published untrimmed and unindexed, silences are not found, and audio is sent to Gemini or Whisper as the original file. Transcription with the chat models that hear audio (OpenAI gpt-audio-1.5, Qwen Omni) needs ffmpeg, because they only accept MP3 or WAV.