- Rust 50.6%
- TypeScript 47.1%
- JavaScript 0.9%
- Swift 0.6%
- CSS 0.5%
- Other 0.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .forgejo/workflows | ||
| backend | ||
| frontend | ||
| scripts | ||
| .dockerignore | ||
| .gitignore | ||
| CLAUDE.md | ||
| Dockerfile | ||
| LICENSE | ||
| muxite.code-workspace | ||
| process-compose.yaml | ||
| README.md | ||
| TODO.md | ||
Muxite
A web UI for inspecting and editing Matroska files in a self-hosted media library. It reads the container itself (no ffprobe) and edits through mkvtoolnix, so metadata changes are instant and only stream removal or conversion needs a remux. An iPhone app wraps the same UI.
Features
- Streams: list every video, audio, subtitle and attachment track, with codec detail Matroska hides behind one CodecID (DTS vs DTS-HD, E-AC-3 vs E-AC-3 Atmos, TrueHD Atmos). Rename, retag language, set default/forced flags, remove tracks, download any track raw.
- Subtitles: visual editor for SRT, ASS/SSA and WebVTT (embedded or sidecar files), raw text editor, shift, add from upload, server path, drag-and-drop or OpenSubtitles search. Bitmap subtitles (PGS, VobSub) are decoded to images for side-by-side OCR with saved drafts.
- Chapters and tags: edit per file, or apply a template across a folder.
- Bulk scripts: a small rule language (
remove subtitle where lang not in (eng, jpn),unset title where type == video,remove tags, …) run against every MKV in a folder, with a dry run that shows the per-file plan first. - Files: move, rename, delete files and folders (sidecar subtitles follow), convert MP4/AVI to MKV, verify MKV structure, filter a library for missing subtitles or language tags.
- Torrents: search every Prowlarr indexer, add to qBittorrent, pick files, hardlink finished downloads into a library. Hidden unless Prowlarr and qBittorrent are configured.
- Jobs: remuxes run in the background with live progress over a WebSocket and can be cancelled.
Running it
Muxite ships as a Docker image built by the build Forgejo Action on every push to main.
muxite:
image: git.buttilda.com/muxite/muxite:latest
container_name: muxite
volumes:
- /media:/media
- ./muxite-auth.toml:/etc/muxite/auth.toml:ro
restart: unless-stopped
environment:
- MEDIA_DIR=/media
- LIBRARIES=Movies,TV Shows
- AUTH_FILE=/etc/muxite/auth.toml
- MUXITE_JWT_SECRET=${MUXITE_JWT_SECRET}
Put it behind any reverse proxy that passes WebSockets (Caddy does by default):
muxite.example.com {
reverse_proxy muxite:3000
}
Authentication
One account, read from the TOML file at AUTH_FILE. Generate the hash with the image:
docker run --rm -it git.buttilda.com/muxite/muxite muxite hash-password
username = "you"
password_hash = "$argon2id$v=19$m=19456,t=2,p=1$..."
Create the file before the first docker compose up, or Docker makes a directory at the mount
path and the server fails with "Is a directory". MUXITE_JWT_SECRET is any long random string
(openssl rand -hex 32); changing it signs everyone out. AUTH_DISABLED=1 runs with no login
at all and is meant for local development only.
Environment variables
| Variable | Default | Description |
|---|---|---|
MEDIA_DIR |
/media |
Root directory containing the libraries |
LIBRARIES |
Movies,TV Shows |
Comma-separated library folder names under MEDIA_DIR |
PORT |
3000 |
HTTP port |
AUTH_FILE |
Credentials file (see above). Required unless AUTH_DISABLED=1 |
|
MUXITE_JWT_SECRET |
Signs session tokens. Required with AUTH_FILE |
|
JOB_PARALLEL |
5 |
Max concurrent remux jobs |
OPENSUBTITLES_API_KEY, OPENSUBTITLES_USERNAME, OPENSUBTITLES_PASSWORD |
Enables OpenSubtitles search | |
QBITTORRENT_URL |
http://qbittorrent:8080 |
qBittorrent WebUI |
QBITTORRENT_USERNAME, QBITTORRENT_PASSWORD |
qBittorrent credentials | |
TORRENT_DOWNLOADS_DIR |
/media/downloads |
Where qBittorrent's save path appears on Muxite's filesystem (for hardlinks) |
PROWLARR_URL, PROWLARR_API_KEY |
Both required for torrent search; the API key is in Prowlarr's Settings → General | |
INDEXER_PROXY |
http:// or socks5:// proxy for indexer detail-page fetches only (see below) |
Indexer detail fetches
The uploader's notes shown under a search result come from the indexer's own detail page, and
those sit behind Cloudflare, which challenges datacenter IPs. INDEXER_PROXY routes only those
fetches through a machine with a residential IP; Prowlarr's own API is never proxied. The
startup log says which path is in use. To check the proxy itself, this must print JSON:
curl -s --proxy "$INDEXER_PROXY" "https://apibay.org/t.php?id=13114578" | head -c 200
Development
Requires Rust (pinned by backend/rust-toolchain.toml), mkvtoolnix, pnpm and
process-compose.
process-compose -p 8083 # backend under watchexec + vite dev server; open http://localhost:5174
The stack runs against the checked-in test-media/ tree with auth disabled. -p 8083 is
needed on every invocation (including down and run): process-compose allows one instance per
port and other projects on this machine hold 8080 and 8082.
Chores are process-compose tasks:
process-compose -p 8083 run fmt # rustfmt + prettier
process-compose -p 8083 run check # what CI checks: fmt, clippy -D warnings, biome, prettier, tsc, build
process-compose -p 8083 run test # cargo test + vitest
process-compose -p 8083 run iphone # the app in the simulator against this stack
process-compose -p 8083 run duo # likewise on an iPhone Duo
process-compose -p 8083 run version -- 1.1.0 # set the version everywhere it lives
Or per side: cargo from backend/ (the toolchain file is looked up from the cwd), and
pnpm dev / compile / lint / test / build from frontend/.
To run the frontend against production data with no local backend:
cd frontend && MUXITE_BACKEND=https://muxite.buttilda.com pnpm dev
That is the live app with the real login. Edits made there are real.
Layout
backend/ Rust axum server. ebml.rs is the Matroska parser; media.rs the subtitle
parsing and mkvtoolnix wrappers; script.rs the bulk-edit DSL; jobs.rs the
job queue; prowlarr.rs / qbittorrent.rs / opensubtitles.rs the integrations.
frontend/ React 19 + TypeScript SPA: vite, TanStack Router (file-based routes),
Tailwind 4, react-aria-components wrapped in components/ui/. api/types.ts
mirrors the serde structs by hand; update it when a Rust response changes.
frontend/src-tauri/ The iPhone app: a Tauri 2 shell around the same bundle, plus a
native tab bar plugin. gen/apple/ is the generated Xcode project, checked in.
scripts/ version.mjs, which writes one version to every file that carries it.
Dockerfile Three stages: frontend check + build, Rust build, Alpine runtime with mkvtoolnix.
process-compose.yaml The dev stack and the tasks above.
The frontend is one bundle for two hosts. lib/net.ts detects whether it is served by the
backend (relative URLs, session cookie) or running inside the Tauri app (user-entered server
URL, bearer token from POST /api/login, native fetch). Nothing else knows the difference.
iPhone app
cd frontend
pnpm tauri:iphone # creates + boots a "Muxite iPhone" simulator, builds, runs
pnpm tauri:duo # likewise "Muxite Duo"
pnpm tauri:ipad # likewise "Muxite iPad"
pnpm icons # regenerate icon sets from frontend/icon.svg
The app asks for a server URL on first launch and keeps it only if /health answers. The web
view has no console; uncaught errors go to the native log, which tauri ios dev streams.
The manual testflight Forgejo workflow ships it from the Mac runner: it archives, exports
and uploads to App Store Connect. Bump the version first (run version -- x.y.z); App Store
Connect refuses a second build for a version it has seen. It needs the APPLE_API_* secrets on
the Muxite org. The app record and store listing live outside this repo.
Deploying a change
Commit and push to main, wait for the build Action, then on the server:
docker compose pull muxite && docker compose up -d muxite