- Rust 61.9%
- CSS 14.3%
- HTML 12.4%
- Nix 6.2%
- JavaScript 4.8%
- Other 0.4%
| migrations | ||
| nix | ||
| src | ||
| static | ||
| templates | ||
| .env.example | ||
| .envrc | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| CLAUDE.md | ||
| docker-compose.yml | ||
| Dockerfile | ||
| flake.lock | ||
| flake.nix | ||
| LICENSE | ||
| README.md | ||
trans-coded
A self-hosted web app to manage your Jellyfin video libraries and transcode files to AV1 with hardware acceleration.
- Backend: Rust (Axum + Tokio), Askama templates + HTMX, single binary
- Database: PostgreSQL (via
sqlx) — tracks libraries, videos, streams, and a transcode job queue - Discovery: Jellyfin REST API for items +
ffprobefor exact codec/subtitle details - Transcode:
ffmpeg→ AV1, copying audio + subtitle streams; encode to temp → verify (codec + duration) → atomic in-place replace. Bulk actions can also pick quality, strip audio/subtitle languages, convert the container, or skip encoding entirely and just remux - Encoders:
vaapi(AMD/Intel, e.g. Radeon 7800 XT),nvenc(NVIDIA Ada/Blackwell),software(libsvtav1) - Auth: local accounts, Argon2 password hashing, Postgres-backed cookie sessions
Quick start (Docker)
cp .env.example .env # edit secrets, Jellyfin URL/key, encoder mode
docker compose up -d
# open http://localhost:8080 (admin / <ADMIN_PASSWORD>)
Mount your media at the same path Jellyfin reports (for atomic in-place
replace), or set JELLYFIN_PATH_MAP=from:to.
Docker GPU setup
docker-compose.yml ships configured for VA-API (AMD/Intel). The other
modes need the edits below — each block is present in the compose file as a
comment.
AMD (VA-API, radeonsi) — the default. Nothing to change beyond your media
path:
environment:
ENCODER_MODE: vaapi
VAAPI_DEVICE: /dev/dri/renderD128
devices:
- "/dev/dri:/dev/dri"
group_add: [video, render]
AV1 encode on RDNA3 (e.g. Radeon 7800 XT) needs Mesa ≥ 24; the runtime image
is Debian trixie (Mesa 25). RDNA2 and older have no AV1 encoder — use
ENCODER_MODE=software there.
Intel (VA-API, iHD) — same passthrough as AMD; the image also ships
intel-media-va-driver. If libva picks the wrong driver, pin it:
environment:
ENCODER_MODE: vaapi
VAAPI_DEVICE: /dev/dri/renderD128
LIBVA_DRIVER_NAME: iHD
devices:
- "/dev/dri:/dev/dri"
group_add: [video, render]
AV1 encode needs Arc (DG2/Battlemage) or Meteor Lake and newer iGPUs; earlier Intel parts can decode AV1 but not encode it.
NVIDIA (NVENC) — no /dev/dri; the GPU comes in through the
NVIDIA container toolkit,
which must be installed and configured on the host. Delete the devices and
group_add keys and use:
environment:
ENCODER_MODE: nvenc
NVIDIA_VISIBLE_DEVICES: all
NVIDIA_DRIVER_CAPABILITIES: compute,video,utility
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu, video]
video in the driver capabilities is what exposes libnvidia-encode; without it
ffmpeg reports no NVENC encoders. AV1 NVENC requires Ada (RTX 40) or newer —
Ampere and older can only encode H.264/HEVC, so use software there.
No GPU — set ENCODER_MODE=software and drop devices/group_add
entirely. libsvtav1 is much slower but has no hardware requirements, and it is
also the encoder used for the DOLBY_VISION_MODE=software lane.
Quick check that the container can see the encoder:
docker compose exec app ffmpeg -hide_banner -encoders | grep av1
# expect av1_vaapi / av1_nvenc / libsvtav1 for your mode
docker compose exec app vainfo # VA-API only: lists the driver + AV1 profiles
Local dev
nix develop # rust toolchain + ffmpeg + psql
docker run -d --name pg -e POSTGRES_USER=transcoded -e POSTGRES_PASSWORD=transcoded \
-e POSTGRES_DB=transcoded -p 5432:5432 postgres:17-alpine
export DATABASE_URL=postgres://transcoded:transcoded@localhost:5432/transcoded
export ADMIN_USERNAME=admin ADMIN_PASSWORD=secret
export ENCODER_MODE=software # reliable without VA-API/NVENC encode support
cargo run
NixOS
Add the flake, then configure declaratively:
# flake inputs: trans-coded.url = "path:/path/to/trans-coded";
{
imports = [ inputs.trans-coded.nixosModules.default ];
nixpkgs.overlays = [ inputs.trans-coded.overlays.default ];
services.trans-coded = {
enable = true;
bindAddr = "0.0.0.0:8080";
database.createLocally = true; # provisions PostgreSQL
admin.passwordFile = "/run/secrets/tc-admin";
jellyfin.url = "http://localhost:8096";
jellyfin.apiKeyFile = "/run/secrets/tc-jf";
jellyfin.pathMap = "/media:/srv/media";
encoder.mode = "vaapi"; # 7800 XT
encoder.vaapiDevice = "/dev/dri/renderD128";
};
# Media must be writable for in-place replace:
systemd.services.trans-coded.serviceConfig.ReadWritePaths = [ "/srv/media" ];
}
Secrets (*File options) are injected via systemd credentials and never enter
the Nix store. Every setting is also available as a plain env var (see
.env.example) — the module just sets those.
How transcoding works
- Sync pulls items from Jellyfin and
ffprobes each file into Postgres. - Queue a job: per-video Transcode, or one of the dashboard's bulk buttons (Transcode all non-AV1 / Re-encode all AV1), which open a settings dialog first — see Bulk queue settings below.
- A worker claims one job at a time (
FOR UPDATE SKIP LOCKED) and runs ffmpeg: video → AV1,-c copyfor the audio/subtitle/attachment streams the job keeps, progress streamed to the DB. - Dolby Vision sources follow
DOLBY_VISION_MODE:softwareuseslibsvtav1to create AV1 Dolby Vision Profile 10,hdr10drops the Dolby Vision RPU and encodes the HDR10 base layer on the configured (hardware) encoder, andskipkeeps the source and records a DB marker so bulk queueing will not retry it. Undersoftwarethe hardware encoders are not used for DV, because this ffmpeg build ignores-dolbyvisionthere — that is what makes DV a CPU-lane job.hdr10avoids the CPU fallback entirely: Profile 8.1 and 10.1 already carry an HDR10-compatible base layer, so nothing has to be converted, andav1_vaapipreserves the colour tags plus the mastering-display and content-light metadata. The trade is losing DV's per-scene dynamic metadata. Profile 5 has no HDR10 base layer and stays skipped under either mode. For ordinary SDR/HDR sources, ffmpeg is told to preserve the source color range, primaries, transfer, matrix, and chroma location tags. - The output is verified (must be AV1, duration within tolerance). If
KEEP_ORIGINAL_IF_LARGER=true, a valid output that is not smaller is deleted, the original is kept, and the video is marked so bulk non-AV1 queueing skips it next time. - Smaller accepted outputs start a best-effort VMAF task while the source and temp output still exist. The task hard-links both files, then runs in the background so the transcode job can finish and the worker can claim more encode work.
- The accepted output is atomically renamed over the original on the same filesystem. VMAF status/score is saved on the video row when the background metric task finishes.
Bulk queue settings
The dashboard's two "transcode all" buttons open a dialog whose choices apply to every job that action queues. Each control carries a ? with a full explanation in the UI; in short:
| Setting | What it does |
|---|---|
| Video | Encode to AV1 re-compresses the picture (the slow part, and the only thing that shrinks the video stream). Don't encode — copy the video remuxes instead: the video stream is copied bit-for-bit and only the container and track list change. A remux runs in seconds, skips VMAF, and is always kept — the size gate does not apply. |
| Quality | Constant-quality target on the CRF scale (0–63): lower is better looking and bigger, ~6 steps roughly halves or doubles the size. Hardware encoders take it ×4 on their own 0–255 quantizer. Defaults to AV1_QUALITY. |
| Audio / subtitle tracks | Which languages to carry over. Kept tracks are copied, never re-encoded, so dropping unwanted dubs costs no quality. Matching is on the language tag; untagged tracks are und. Selecting none drops every track of that kind, and an audio filter that matches nothing keeps the first track rather than leaving a silent file. |
| Container | Keep each source's container (replaces in place), or convert to MKV/MP4. MP4 drops image-based subtitles (PGS/VobSub) and font attachments and converts text subtitles to mov_text. Converting renames the file and deletes the original, so Jellyfin needs a rescan. |
The per-video Transcode buttons don't prompt — they always use the configured defaults.
Configuration
Environment Variables
| Variable | Default | Description |
|---|---|---|
APP_BIND_ADDR |
0.0.0.0:8080 |
HTTP listen address. |
COOKIE_SECURE |
false |
Mark session cookies Secure; use true behind HTTPS. |
STATIC_DIR |
static |
Runtime static asset directory. |
DATABASE_URL |
required | PostgreSQL connection string. |
DB_MAX_CONNECTIONS |
5 |
Maximum Postgres pool size. |
ADMIN_USERNAME |
unset | Bootstrap admin username, created/updated at startup when paired with ADMIN_PASSWORD. |
ADMIN_PASSWORD |
unset | Bootstrap admin password. |
JELLYFIN_URL |
unset | Base Jellyfin URL. |
JELLYFIN_API_KEY |
unset | Jellyfin API key. |
JELLYFIN_USER_ID |
unset | Optional Jellyfin user id; first user is used when unset. |
JELLYFIN_PATH_MAP |
unset | Optional from:to path remap from Jellyfin paths to local paths. |
SCAN_ON_START |
false |
Run a Jellyfin sync when the service starts. |
ENCODER_MODE |
vaapi |
vaapi, nvenc, or software. |
VAAPI_DEVICE |
/dev/dri/renderD128 |
VA-API render node. |
HWACCEL_DECODE |
true |
Decode on GPU as well as encode; unsupported inputs may fail. Toggleable from Jobs page. |
FFMPEG_BIN |
ffmpeg |
ffmpeg binary path. |
FFPROBE_BIN |
ffprobe |
ffprobe binary path. |
VMAF_ENABLED |
true |
Run best-effort post-encode VMAF and save status/score on the video row. |
VMAF_FFMPEG_BIN |
FFMPEG_BIN |
ffmpeg binary used for VMAF. It must include the libvmaf filter; the NixOS module uses pkgs.ffmpeg-full. |
VMAF_HWACCEL_DECODE |
true in vaapi mode, otherwise false |
Use VA-API decode for VMAF inputs before downloading sampled frames for libvmaf. |
VMAF_SAMPLE_INTERVAL |
24 |
Score every Nth frame. 24 is roughly 1 fps for common 23.976/24 fps sources; 1 scores every frame. |
VMAF_THREADS |
0 |
libvmaf worker threads; 0 lets libvmaf choose. |
VMAF_CONCURRENCY |
1 |
Maximum concurrent VMAF processes. Waiting VMAF tasks do not occupy transcode worker slots. |
AV1_QUALITY |
28 |
Software libsvtav1 CRF. Lower is better/larger. |
AV1_QP |
AV1_QUALITY * 4, clamped 1..255 |
Hardware quality. VA-API uses -global_quality; NVENC uses -qp. Higher is smaller. |
KEEP_ORIGINAL_IF_LARGER |
true |
Delete valid AV1 outputs that are not smaller, keep the source, and mark the video skipped for bulk queueing. |
DOLBY_VISION_MODE |
software |
software creates AV1 Dolby Vision Profile 10 with libsvtav1 (CPU lane); hdr10 drops the DV RPU and encodes the HDR10 base layer on the configured encoder, keeping DV on the GPU; skip keeps the source and marks it blocked for bulk queueing. Profile 5 is skipped under software and hdr10 alike. |
SVT_PRESET |
6 |
libsvtav1 preset, 0 slowest/best to 13 fastest. |
TRANSCODE_TEMP_DIR |
unset | Parsed for future use; current temp outputs stay next to the source so replace is atomic. |
TRANSCODE_CONCURRENCY |
1 |
Initial GPU (hardware) encode concurrency; usually single-lane. In software encoder mode it seeds the CPU limit instead. |
TRANSCODE_CPU_CONCURRENCY |
1 |
Initial CPU (software / Dolby Vision fallback) encode concurrency, kept separate from the GPU lane so it can't clobber the CPU while GPU jobs run. Only meaningful in a hardware encoder mode. Both limits are live-tunable from the Jobs UI. |
WORKER_POLL_SECONDS |
5 |
Worker queue polling interval. |
SKIP_CODECS |
av1 |
Parsed for future codec-skip behavior; current bulk queueing skips AV1 plus videos with a persisted AV1 transcode block marker. |
CUSTOM_THEME_CSS |
built-in template | Starter CSS for the in-app Theme ▸ Custom… editor. See Theming. |
CUSTOM_THEME_CSS_FILE |
unset | Path to a .css file used as that starter instead. Wins over CUSTOM_THEME_CSS; unreadable or empty falls back to the built-in template. |
RUST_LOG |
info,sqlx=warn |
Tracing filter. |
NixOS Options
All NixOS module options map to the environment variables above or to systemd service settings.
| Option | Default | Description |
|---|---|---|
services.trans-coded.enable |
false |
Enable the service. |
services.trans-coded.package |
pkgs.trans-coded |
Package to run. |
services.trans-coded.ffmpegPackage |
pkgs.ffmpeg |
Package providing ffmpeg and ffprobe. |
services.trans-coded.vmaf.enable |
true |
Sets VMAF_ENABLED. |
services.trans-coded.vmaf.ffmpegPackage |
pkgs.ffmpeg-full |
Package providing the VMAF ffmpeg binary; sets VMAF_FFMPEG_BIN. |
services.trans-coded.vmaf.hwDecode |
true |
Sets VMAF_HWACCEL_DECODE. |
services.trans-coded.vmaf.sampleInterval |
24 |
Sets VMAF_SAMPLE_INTERVAL. |
services.trans-coded.vmaf.threads |
0 |
Sets VMAF_THREADS. |
services.trans-coded.vmaf.concurrency |
1 |
Sets VMAF_CONCURRENCY. |
services.trans-coded.user |
trans-coded |
System user. Added to video and render. |
services.trans-coded.group |
trans-coded |
Primary group. |
services.trans-coded.bindAddr |
0.0.0.0:8080 |
HTTP listen address. |
services.trans-coded.cookieSecure |
false |
Sets COOKIE_SECURE. |
services.trans-coded.theme.customTemplate |
null |
Starter CSS for the in-app custom theme editor; written to the store and passed as CUSTOM_THEME_CSS_FILE. |
services.trans-coded.theme.customTemplateFile |
null |
Path to a CSS file used as that starter instead. Takes precedence over theme.customTemplate. |
services.trans-coded.logLevel |
info,sqlx=warn |
Sets RUST_LOG. |
services.trans-coded.scanOnStart |
false |
Sets SCAN_ON_START. |
services.trans-coded.database.createLocally |
true |
Provision local PostgreSQL database and role. |
services.trans-coded.database.name |
transcoded |
Local database name. |
services.trans-coded.database.url |
local socket URL | Sets DATABASE_URL; required when createLocally = false. |
services.trans-coded.admin.username |
admin |
Sets ADMIN_USERNAME. |
services.trans-coded.admin.passwordFile |
null |
File loaded as ADMIN_PASSWORD via systemd credentials. |
services.trans-coded.jellyfin.url |
null |
Sets JELLYFIN_URL. |
services.trans-coded.jellyfin.apiKeyFile |
null |
File loaded as JELLYFIN_API_KEY via systemd credentials. |
services.trans-coded.jellyfin.userId |
null |
Sets JELLYFIN_USER_ID. |
services.trans-coded.jellyfin.pathMap |
null |
Sets JELLYFIN_PATH_MAP. |
services.trans-coded.encoder.mode |
vaapi |
Sets ENCODER_MODE; one of vaapi, nvenc, software. |
services.trans-coded.encoder.vaapiDevice |
/dev/dri/renderD128 |
Sets VAAPI_DEVICE. |
services.trans-coded.encoder.quality |
28 |
Sets software AV1_QUALITY. |
services.trans-coded.encoder.hardwareQuality |
encoder.quality * 4, clamped 1..255 |
Sets AV1_QP; VA-API -global_quality, NVENC -qp. |
services.trans-coded.encoder.keepOriginalIfLarger |
true |
Sets KEEP_ORIGINAL_IF_LARGER. |
services.trans-coded.encoder.dolbyVisionMode |
software |
Sets DOLBY_VISION_MODE; one of software, hdr10, skip. |
services.trans-coded.encoder.svtPreset |
6 |
Sets SVT_PRESET. |
services.trans-coded.encoder.concurrency |
1 |
Sets TRANSCODE_CONCURRENCY. |
services.trans-coded.encoder.cpuConcurrency |
1 |
Sets TRANSCODE_CPU_CONCURRENCY. |
services.trans-coded.extraEnvironment |
{ } |
Extra environment entries passed through verbatim. |
Media paths must still be made writable with systemd settings, for example
systemd.services.trans-coded.serviceConfig.ReadWritePaths = [ "/srv/media" ];.
License
trans-coded is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License v3.0 or later as published by the Free Software Foundation. See LICENSE for the full text.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
SPDX identifier: GPL-3.0-or-later.