$ av1dae

A folder-watching daemon that turns .mkv rips into SVT-AV1 + Opus, tags them with metadata from OMDb and TVmaze, and files the results — untouched by you after the drop.

drop .mkv ffprobe extract wav opus 128k SVT-AV1 mux + tag output/
libsvtav1 · yuv420p10le opus 128k OMDb · TVmaze polls every 15s docker ready
Contents

User Manual

  1. Requirements
  2. Build
  3. Configuration
  4. Running
  5. Filename convention
  6. Processing pipeline
  7. Output naming
  8. Logs
  9. Failure handling
  10. Polling behavior
  11. Project layout
  12. Deploy with Docker

01 — Prerequisites

Requirements

These external binaries must be on $PATH. The program checks them at startup and exits if any are missing:

BinaryRole
ffmpegVideo transcode + interlace detection
ffprobeStream + language probing
opusencAudio 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.

Running in Docker?

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).

dvd
SD sources
crf30
preset2
bluray
HD sources
crf29
preset3
webdl
Token only
crf30
preset3
tvrip
Token only
crf32
preset2

Field reference

FieldMeaningDefault
omdb_api_keyOMDb key for movie metadata lookupnone — movies get no metadata without it
paths.inputFolder polled for new .mkv files./input
paths.outputDestination for finished encodes./output
paths.originalsWhere sources move on success (unless -d)./originals
paths.failedWhere sources go on failure./failed
paths.workPer-job scratch (wav/opus/output.mkv); wiped each job./work
Note

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
FlagEffect
-dDelete the source .mkv after a successful encode, instead of moving it to originals/.
-c PATHPath to config file.
Shutdown

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.mkv

The token controls both the ORIGINAL_MEDIA_TYPE metadata tag and which encoding.<type> crf/preset pair is used.

Fallback

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.

  1. Parse filename

    Determines whether this is a movie or series, and what IDs to use.
  2. 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 the Multi frame detection summary. Interlaced only when TFF+BFF > Progressive; undetermined frames are ignored, a missing line defaults to progressive.
  3. Probe stream languages (ffprobe)

    Collects language tags for every audio/subtitle stream so they survive — -map_metadata -1 strips them otherwise.
  4. Detect media type

    • Filename token (dvd/bluray/webdl/tvrip) wins if present.
    • Otherwise pixel count: w × h < 600,000 → DVD, else Blu-ray.
    The chosen profile selects the crf/preset pair and is written to ORIGINAL_MEDIA_TYPE.
  5. 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.
  6. 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.
  7. Encode audio (opusenc)

    Each wav → audio.<n>.opus at --bitrate 128k.
  8. Calculate display width from SAR

    Rescale only when there's work to do — zscale is skipped for square-pixel sources (SAR 1: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).
  9. Encode video (ffmpeg → libsvtav1)

    • Filter chain built conditionally: bwdif=mode=0:par=-1:-1 prepended when interlaced; zscale appended only when a rescale is needed. Neither → -vf omitted.
    • 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, subtitles 0:s? (optional), one audio per opus file (1:a, 2:a, …). Audio -c:a copy, subtitles -c:s copy.
    • -map_metadata -1 strips 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 — plus COLLECTION, SEASON, EPISODE, TVMAZE_ID for series. Values unquoted. Output → output.mkv in the work dir.
  10. Clean up the work directory

    Intermediates and output.mkv together — once the move below succeeds, or on any failure via deferred cleanup.
  11. 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.
    Sanitization keeps a–z A–Z 0–9 - ä ö Ä Ö only.
  12. Dispose of the source

    -d set → delete the original; otherwise → move it to paths.originals.

07 — Reference

Output naming examples

InputResult in output/Notes
Heat.tt0113277.mkvHeat.tt0113277.mkvPixel-count fallback → Blu-ray profile + tag
Heat.tt0113277.bluray.mkvHeat.tt0113277.mkvSame name; ORIGINAL_MEDIA_TYPE now from token, not guess
Breaking.Bad.tvm169.S01E01.webdl.mkvBreakingBad.S01E01.mkvWebDL profile + tag
unrecognized-rip.mkva1b2c3d4.nometadata.mkvNo IDs at all
A few things worth knowing
  • The media-type token affects the muxed ORIGINAL_MEDIA_TYPE tag 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>.mkv filename; only the IMDBID tag is left empty.

08 — Observability

Logs

All logs are written to a SQLite database, logs.db, in the current working directory (not the config paths). Run the program from where you want it to land — in Docker that's the mounted /data. The logs table has columns id, ts, level, message, file, extra.

LevelWhereContents
infodb + stdoutProcessing / metadata hits / completions
errordb + stderrFailures, with the source file recorded
debugdb onlyffprobe output, zscale width, full ffmpeg/opusenc command, OMDb/TVmaze responses (API key redacted) in extra

Live encode progress (encoding … · 47% · 3.2fps · …) prints to stdout only and is deliberately not stored, so it can't flood the database.

Retention

Rows older than log_retention_days (default 7) are purged on startup and shutdown. Editable live from the settings page (/settings); applies at the next purge. Recent non-debug events are viewable in the dashboard and via GET /status when http_addr is set.

09 — Resilience

Failure handling

If any step from probing through encoding through renaming fails:

  • The source .mkv is moved to paths.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 leaves paths.input on 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 logs.db (level error) with the source path, and printed to stderr.

The watcher continues with the next file; one bad rip won't stop the daemon.

Collisions

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.

Cross-filesystem

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 *.mkv directly in paths.input are picked up — no recursion.
  • Files are processed sequentially, one at a time, in the order filepath.Glob returns (alphabetical on Linux).
  • Partial-write protection: a file's mtime and size must 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 into paths.input is safe — no need to land it under a different name and mv into place (though that still works and skips the tick delay).
  • Failure quarantine: if processFile errors, that file is skipped for 5 minutes (or until its mtime changes — e.g. you replace or touch it). 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 job

12 — 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 --build

Drop .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:

HostContainerHolds
./config.yaml/config/config.yaml (ro)Your config — passed via -c
./media/datainput/ output/ originals/ failed/ work/ + logs.db and log files
Paths just work

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

LayerDetail
Build stagegolang:1.26-bookworm, CGO_ENABLED=1 (the SQLite logger needs cgo — no scratch image)
Runtimedebian:bookworm-slim
Bundledffmpeg (ships ffprobe), opus-tools (opusenc), ca-certificates (for OMDb/TVmaze HTTPS)
Delete originals

To delete sources after a successful encode (the -d flag), uncomment command: ["-d"] in docker-compose.yml and re-run docker compose up -d.

Clean shutdown

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