diff --git a/MANUAL.html b/MANUAL.html new file mode 100644 index 0000000..811ea3a --- /dev/null +++ b/MANUAL.html @@ -0,0 +1,662 @@ + + + + + +videnc·vibe — User Manual + + + + +
+
+

$ videnc·vibe

+

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. +
  3. Build
  4. +
  5. Configuration
  6. +
  7. Running
  8. +
  9. Filename convention
  10. +
  11. Processing pipeline
  12. +
  13. Output naming
  14. +
  15. Logs
  16. +
  17. Failure handling
  18. +
  19. Polling behavior
  20. +
  21. Project layout
  22. +
  23. Deploy with Docker
  24. +
+
+ +
+ +
+

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 videnc-vibe ./cmd/videnc/
+

This produces a self-contained binary ./videnc-vibe (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/videnc-vibe/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

+
./videnc-vibe                       # default config, keep originals
+./videnc-vibe -d                    # delete originals after success
+./videnc-vibe -c /etc/videnc.yaml   # custom config
+./videnc-vibe -c /etc/videnc.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. +
  3. +

    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.
    • +
    +
  4. +
  5. +

    Probe stream languages (ffprobe)

    + Collects language tags for every audio/subtitle stream so they survive — -map_metadata -1 strips them otherwise. +
  6. +
  7. +

    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. +
  8. +
  9. +

    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. +
  10. +
  11. +

    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. +
  12. +
  13. +

    Encode audio (opusenc)

    + Each wav → audio.<n>.opus at --bitrate 128k. +
  14. +
  15. +

    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). +
  16. +
  17. +

    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.
    • +
    +
  18. +
  19. +

    Clean up the work directory

    + Intermediates and output.mkv together — once the move below succeeds, or on any failure via deferred cleanup. +
  20. +
  21. +

    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. +
  22. +
  23. +

    Dispose of the source

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

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

+

Three log files are written to the current working directory (not the config paths):

+
+ + + + + + + +
FileContents
info_YYYY-MM-DD.logINFO messages, dated
error_YYYY-MM-DD.logERROR messages, dated
structured.jsonOne 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.

+
Known limitation

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 .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 error_*.log and structured.json with the source path.
  • +
+

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/videnc/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 videnc-vibe .
+docker run -d --name videnc-vibe --restart unless-stopped \
+  -v "$PWD/config.yaml:/config/config.yaml:ro" \
+  -v "$PWD/media:/data" \
+  videnc-vibe
+
+ +
+
+ + + + + + +