01 — Prerequisites
Requirements
These external binaries must be on $PATH. The program checks them at startup and exits if any are missing:
| Binary | Role |
|---|---|
ffmpeg | Video transcode + interlace detection |
ffprobe | Stream + language probing |
opusenc | Audio encoding |
To build: Go 1.x with module support.
API keys: an OMDb API key (free at omdbapi.com) is required for movie metadata. TVmaze is unauthenticated.
The three binaries above are baked into the image — the only host requirement is Docker. Skip to §12 — Deploy with Docker.
02 — Compile
Build
go build -o av1dae ./cmd/av1dae/This produces a self-contained binary ./av1dae (cgo is used for the SQLite logger, so it links against the system libc). To skip installing Go and the encoders on the host entirely, build the container instead — §12.
03 — Setup
Configuration
By default the config loads from ~/.config/av1dae/config.yaml. Pass -c /path/to/config.yaml to override.
omdb_api_key: "YOUR_API_KEY_HERE"
encoding:
dvd: { crf: 30, preset: 2 }
bluray: { crf: 29, preset: 3 }
webdl: { crf: 30, preset: 3 }
tvrip: { crf: 32, preset: 2 }
paths:
input: "./input"
output: "./output"
originals: "./originals"
failed: "./failed"
work: "./work"Encoding profiles
Each source type carries its own SVT-AV1 quality pair. The type is chosen by a filename token, or guessed from pixel count (see §5).
Field reference
| Field | Meaning | Default |
|---|---|---|
omdb_api_key | OMDb key for movie metadata lookup | none — movies get no metadata without it |
paths.input | Folder polled for new .mkv files | ./input |
paths.output | Destination for finished encodes | ./output |
paths.originals | Where sources move on success (unless -d) | ./originals |
paths.failed | Where sources go on failure | ./failed |
paths.work | Per-job scratch (wav/opus/output.mkv); wiped each job | ./work |
All five directories are created on startup if they don't already exist.
04 — Operate
Running
./av1dae # default config, keep originals
./av1dae -d # delete originals after success
./av1dae -c /etc/av1dae.yaml # custom config
./av1dae -c /etc/av1dae.yaml -d| Flag | Effect |
|---|---|
-d | Delete the source .mkv after a successful encode, instead of moving it to originals/. |
-c PATH | Path to config file. |
Stop with Ctrl+C (SIGINT) or SIGTERM. The signal cancels any in-flight encode immediately — the ffmpeg / ffprobe / opusenc children are killed, the per-job work directory is removed by its deferred cleanup, and the source .mkv is routed to paths.failed so the next run sees a clean paths.input.
The program runs as a foreground daemon. It scans the input directory on startup and every 15 seconds thereafter.
05 — Input
Filename convention
The base name of each .mkv in paths.input is parsed to decide what metadata to fetch. Three forms are recognized.
5.1 — Movies: IMDb ID
Filename must contain an IMDb tag of the form tt<digits>.
Heat.tt0113277.mkv
some-rip-tt0114369.mkv→ OMDb is queried for that IMDb ID. Title, release date, and IMDb ID are embedded.
5.2 — Series: TVmaze ID + season/episode
Filename must contain both tvm<digits> (the TVmaze show ID) and s<digits>e<digits> (season/episode). Both are case-insensitive.
Breaking.Bad.tvm169.S01E01.mkv
the-wire.TVM75.s2e5.mkv→ TVmaze is queried for that show/season/episode. Show name (Collection), episode title, season, episode, airdate, and the show's IMDb ID are embedded.
5.3 — No recognizable tags
If neither pattern matches, encoding still proceeds but the file is treated as having no metadata. The output is named with a random hex string and an .nometadata.mkv suffix.
5.4 — Optional: source media type
Any filename can additionally carry a media-type token (case-insensitive, word-bounded): dvd, bluray, webdl, or tvrip.
Heat.tt0113277.bluray.mkv
some-rip.tvm169.S01E01.webdl.mkv
old.broadcast.tvrip.tt0066026.mkvThe token controls both the ORIGINAL_MEDIA_TYPE metadata tag and which encoding.<type> crf/preset pair is used.
With no token, the type is guessed from pixel count: width × height < 600,000 → DVD, otherwise Blu-ray. WebDL and TVRip are never auto-detected — they must be declared via the token.
06 — Signature flow
The processing pipeline
For every .mkv in paths.input, these steps run in order. A per-job scratch subdir under paths.work (named after the input base name) holds all intermediates and is deleted unconditionally at the end. Any error sends the source to paths.failed; the work subdir is wiped regardless.
-
Parse filename
Determines whether this is a movie or series, and what IDs to use. -
Probe video (ffprobe)
- Picks the first stream with
codec_type=video, regardless of codec. Errors out if there isn't one. - Records width, height, and sample aspect ratio (SAR).
- Detects interlacing via
ffmpeg -vf idet -frames:v 400 -an -sn -f null -, parsing theMulti frame detectionsummary. Interlaced only whenTFF+BFF > Progressive; undetermined frames are ignored, a missing line defaults to progressive.
- Picks the first stream with
-
Probe stream languages (ffprobe)
Collectslanguagetags for every audio/subtitle stream so they survive —-map_metadata -1strips them otherwise. -
Detect media type
- Filename token (
dvd/bluray/webdl/tvrip) wins if present. - Otherwise pixel count:
w × h < 600,000→ DVD, else Blu-ray.
ORIGINAL_MEDIA_TYPE. - Filename token (
-
Fetch metadata
From OMDb or TVmaze per the parsed filename. Failures here are logged but do not abort the encode — the file is just encoded without metadata. -
Extract audio
One PCM wav per source audio stream →audio.0.wav,audio.1.wav, … (PCM s16le, 48 kHz). Errors out if there are no audio streams. -
Encode audio (opusenc)
Each wav →audio.<n>.opusat--bitrate 128k. -
Calculate display width from SAR
Rescale only when there's work to do —zscaleis skipped for square-pixel sources (SAR1:1,N/A, empty,0:N) and for any SAR whose width rounds back to the source width. When rescaling, width is rounded to the nearest even number (mod-2, preferred by AV1). -
Encode video (ffmpeg → libsvtav1)
- Filter chain built conditionally:
bwdif=mode=0:par=-1:-1prepended when interlaced;zscaleappended only when a rescale is needed. Neither →-vfomitted. - Codec
libsvtav1,-pix_fmt yuv420p10le, crf/preset from the profile. -svtav1-params film-grain=10:film-grain-denoise=1:scd=1:qm-min=4:qm-max=15:keyint=10s- Maps: video
0:v, subtitles0:s?(optional), one audio per opus file (1:a,2:a, …). Audio-c:a copy, subtitles-c:s copy. -map_metadata -1strips global metadata, then language tags are re-applied — audio indexed by output position, so missing-language streams don't shift the index.- Container tags:
TITLE,DATE_RELEASED,IMDBID,ORIGINAL_MEDIA_TYPE— plusCOLLECTION,SEASON,EPISODE,TVMAZE_IDfor series. Values unquoted. Output →output.mkvin the work dir.
- Filter chain built conditionally:
-
Clean up the work directory
Intermediates andoutput.mkvtogether — once the move below succeeds, or on any failure via deferred cleanup. -
Rename and move to paths.output
- Series with a known show name →
<show>.S<NN>E<NN>.mkv(no IMDb mapping required). - Movie with title + IMDb ID →
<title>.<imdbID>.mkv. - Anything else →
<8-hex>.nometadata.mkv.
a–z A–Z 0–9 - ä ö Ä Öonly. - Series with a known show name →
-
Dispose of the source
-dset → delete the original; otherwise → move it topaths.originals.
07 — Reference
Output naming examples
| Input | Result in output/ | Notes |
|---|---|---|
Heat.tt0113277.mkv | Heat.tt0113277.mkv | Pixel-count fallback → Blu-ray profile + tag |
Heat.tt0113277.bluray.mkv | Heat.tt0113277.mkv | Same name; ORIGINAL_MEDIA_TYPE now from token, not guess |
Breaking.Bad.tvm169.S01E01.webdl.mkv | BreakingBad.S01E01.mkv | WebDL profile + tag |
unrecognized-rip.mkv | a1b2c3d4.nometadata.mkv | No IDs at all |
A few things worth knowing
- The media-type token affects the muxed
ORIGINAL_MEDIA_TYPEtag and the crf/preset profile, but not the output filename. - The IMDb ID in the filename is the one returned by the API, not the one in the input — a typo in the source name will surface in the output name.
- A TVmaze show with no IMDb mapping still gets a
<Collection>.S<NN>E<NN>.mkvfilename; only theIMDBIDtag is left empty.
08 — Observability
Logs
Three log files are written to the current working directory (not the config paths):
| File | Contents |
|---|---|
info_YYYY-MM-DD.log | INFO messages, dated |
error_YYYY-MM-DD.log | ERROR messages, dated |
structured.json | One JSON object per line — every entry, with timestamp, level, message, optional error & file |
Run the program from the directory where you want the logs to land. A number of DEBUG lines also print to stdout/stderr (ffprobe output, calculated zscale width, the full ffmpeg command) — intentional, but not written to the log files.
The date in info_*.log / error_*.log filenames is computed at daemon start and does not roll over at midnight. Left running across days, all writes continue into the start-day's file — restart to rotate. structured.json does not rotate at all.
09 — Resilience
Failure handling
If any step from probing through encoding through renaming fails:
- The source
.mkvis moved topaths.failed(os.Stat-guarded — if the source is already gone the move is skipped and logged; a move error is logged too). This guarantees the source leavespaths.inputon every failure path, so the watcher won't retry it next tick. - The per-job work directory (partial wav/opus/output.mkv) is deleted unconditionally.
- The error is logged to
error_*.logandstructured.jsonwith the source path.
The watcher continues with the next file; one bad rip won't stop the daemon.
If a finished encode would land on a name that already exists in paths.output, the move is refused (no silent overwrite) and the source is routed to paths.failed. This is the path you hit when two sources sanitize to the same name — two re-rips of the same release, or two episodes that both resolve to SxxExx.
Every move (input→output, →failed, →originals) transparently falls back to copy + delete when source and destination live on different mounts. Put each path on a different drive without breaking the pipeline.
10 — Watcher
Polling behavior
- Input directory is scanned every 15 seconds (clamped to a 10–30 s range).
- Only
*.mkvdirectly inpaths.inputare picked up — no recursion. - Files are processed sequentially, one at a time, in the order
filepath.Globreturns (alphabetical on Linux). - Partial-write protection: a file's
mtimeandsizemust match on two consecutive scans before processing. A freshly dropped or still-copying file waits at least one full tick (~15 s). Copying a large file straight intopaths.inputis safe — no need to land it under a different name andmvinto place (though that still works and skips the tick delay). - Failure quarantine: if
processFileerrors, that file is skipped for 5 minutes (or until itsmtimechanges — e.g. you replace ortouchit). Stops a permanently-broken input from spamming the logs. In-memory only; restarting clears it.
11 — Source map
Project layout
cmd/av1dae/main.go CLI entrypoint and per-file orchestration
internal/config/ YAML config load + defaults + mkdir
internal/watcher/ Polling loop, media-type detection by pixel count
internal/encoder/ ffprobe/ffmpeg/opusenc wrapper, transcode pipeline
internal/metadata/ Filename parsing, OMDb + TVmaze clients
internal/mover/ File rename/move/delete helpers
internal/logger/ Plain + JSON logging
pkg/types/types.go Shared structs (Config, Job, Metadata, …)Runtime directories (from paths.* in the config):
paths.input Drop new .mkv here; polled every 15 s
paths.output Finished encodes land here under their final name
paths.originals Encoded sources end up here (unless -d)
paths.failed Sources of failed jobs end up here
paths.work Per-job scratch subdir (basename); wiped per job12 — Deploy
Deploy with Docker
Run it as a container on your server. ffmpeg, ffprobe, and opusenc are baked into the image, so the only host requirement is Docker — nothing to install, nothing on $PATH.
Quick start
# one-time: create the media tree and your config
mkdir -p media/input media/output media/originals media/failed media/work
cp config.example.yaml config.yaml # then fill in omdb_api_key
docker compose up -d --buildDrop .mkv files into media/input/; finished encodes appear in media/output/. Follow the logs with docker compose logs -f.
How the volumes map
Two mounts (defined in docker-compose.yml) are all it needs:
| Host | Container | Holds |
|---|---|---|
./config.yaml | /config/config.yaml (ro) | Your config — passed via -c |
./media | /data | input/ output/ originals/ failed/ work/ + logs.db and log files |
The container's working directory is /data, so the relative paths in config.example.yaml (./input, ./output, …) resolve to /data/input, /data/output, … inside the mount. No path edits needed — only the omdb_api_key.
What's in the image
| Layer | Detail |
|---|---|
| Build stage | golang:1.26-bookworm, CGO_ENABLED=1 (the SQLite logger needs cgo — no scratch image) |
| Runtime | debian:bookworm-slim |
| Bundled | ffmpeg (ships ffprobe), opus-tools (opusenc), ca-certificates (for OMDb/TVmaze HTTPS) |
To delete sources after a successful encode (the -d flag), uncomment command: ["-d"] in docker-compose.yml and re-run docker compose up -d.
docker stop sends SIGTERM, which cancels any in-flight encode and routes the source to failed/ (see §4). Restart with docker compose restart; the daemon re-scans input/ on boot.
Without compose
Same thing with plain docker:
docker build -t av1dae .
docker run -d --name av1dae --restart unless-stopped \
-v "$PWD/config.yaml:/config/config.yaml:ro" \
-v "$PWD/media:/data" \
av1dae