Files
av1dae/MANUAL.html
T
Esa Kataja 9bb7c72c1a refactor: rename project videnc-vibe -> av1dae
Rename the Go module, the cmd/ entrypoint dir, the binary, the default config
dir (~/.config/av1dae/), the Docker image/compose service, and all docs and
the dashboard wordmark. No behavioral change — import paths and identifiers
only.
2026-06-21 19:02:06 +03:00

663 lines
38 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>av1dae — User Manual</title>
<style>
:root {
--bg:#0d1117; --panel:#151b23; --panel-2:#1a2230; --line:#26303f;
--text:#cdd5df; --muted:#7d8896; --dim:#5a6573;
--amber:#ffb454; --amber-soft:#ffd9a0; --cyan:#56c7e8; --green:#5cc98b; --red:#f0816a;
--mono:"SF Mono",ui-monospace,"JetBrains Mono","Cascadia Code",Menlo,Consolas,monospace;
--sans:"Inter",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
--measure:64ch;
}
* { box-sizing:border-box; }
html { scroll-behavior:smooth; }
@media (prefers-reduced-motion:reduce){ html{scroll-behavior:auto;} *{transition:none!important;} }
body {
margin:0; background:var(--bg); color:var(--text);
font-family:var(--sans); font-size:16px; line-height:1.65;
-webkit-font-smoothing:antialiased;
}
a { color:var(--cyan); text-decoration:none; }
a:hover { text-decoration:underline; }
code { font-family:var(--mono); font-size:.86em; }
:focus-visible { outline:2px solid var(--amber); outline-offset:3px; border-radius:3px; }
/* ---- hero ---- */
.hero {
border-bottom:1px solid var(--line);
background:
radial-gradient(120% 140% at 85% -10%, rgba(255,180,84,.10), transparent 55%),
radial-gradient(90% 120% at 5% 0%, rgba(86,199,232,.06), transparent 50%);
padding:clamp(2.5rem,6vw,4.5rem) clamp(1.25rem,5vw,3rem) clamp(2rem,4vw,3rem);
}
.hero-inner { max-width:1180px; margin:0 auto; }
.wordmark {
font-family:var(--mono); font-weight:600;
font-size:clamp(2.1rem,6vw,3.6rem); letter-spacing:-.02em; line-height:1;
margin:0; color:var(--text);
}
.wordmark .prompt { color:var(--dim); }
.wordmark .vibe { color:var(--amber); }
.thesis {
max-width:60ch; margin:1.4rem 0 0; font-size:clamp(1.05rem,2.2vw,1.3rem);
line-height:1.5; color:var(--text);
}
.thesis b { color:var(--amber-soft); font-weight:600; }
.flow {
display:flex; flex-wrap:wrap; align-items:center; gap:.35rem .15rem;
margin:2rem 0 0; font-family:var(--mono); font-size:.8rem;
}
.flow .node {
background:var(--panel); border:1px solid var(--line); color:var(--text);
padding:.35rem .7rem; border-radius:5px; white-space:nowrap;
}
.flow .node.accent { border-color:var(--amber); color:var(--amber); }
.flow .arr { color:var(--dim); padding:0 .15rem; }
.specstrip {
display:flex; flex-wrap:wrap; gap:.5rem .6rem; margin:1.6rem 0 0;
font-family:var(--mono); font-size:.74rem; color:var(--muted);
}
.specstrip span { display:inline-flex; align-items:center; gap:.4rem; }
.specstrip span::before { content:""; width:6px; height:6px; border-radius:50%; background:var(--green); }
/* ---- layout ---- */
.shell { max-width:1180px; margin:0 auto; display:grid; grid-template-columns:240px 1fr; gap:0; }
.toc {
border-right:1px solid var(--line);
align-self:start; position:sticky; top:0; max-height:100vh; overflow-y:auto;
padding:2rem 1.25rem 3rem;
}
.toc > summary { display:none; }
.toc .toc-title {
font-family:var(--mono); font-size:.7rem; letter-spacing:.18em; text-transform:uppercase;
color:var(--dim); margin:0 0 1rem .5rem;
}
.toc ol { list-style:none; margin:0; padding:0; counter-reset:toc; }
.toc li { counter-increment:toc; }
.toc a {
display:flex; gap:.6rem; padding:.32rem .5rem; border-radius:6px;
color:var(--muted); font-size:.88rem; line-height:1.3; border-left:2px solid transparent;
}
.toc a::before { content:counter(toc,decimal-leading-zero); color:var(--dim); font-family:var(--mono); font-size:.72rem; }
.toc a:hover { color:var(--text); background:var(--panel); text-decoration:none; }
.toc a.active { color:var(--amber); border-left-color:var(--amber); background:var(--panel); }
.toc a.active::before { color:var(--amber); }
main { padding:2.5rem clamp(1.25rem,4vw,3.5rem) 6rem; min-width:0; }
/* ---- sections ---- */
section { padding:2.5rem 0; border-top:1px solid var(--line); scroll-margin-top:1.5rem; }
section:first-child { border-top:0; padding-top:.5rem; }
.eyebrow {
font-family:var(--mono); font-size:.72rem; letter-spacing:.14em; text-transform:uppercase;
color:var(--amber); margin:0 0 .5rem;
}
h2 { font-size:clamp(1.5rem,3vw,2rem); line-height:1.15; margin:0 0 1.2rem; letter-spacing:-.01em; }
h3 { font-size:1.12rem; margin:2rem 0 .6rem; color:var(--amber-soft); }
p, li { max-width:var(--measure); }
main ul, main ol { padding-left:1.3rem; }
main li { margin:.3rem 0; }
strong { color:#e7edf4; }
.lead { font-size:1.08rem; color:var(--text); }
/* inline code */
p code, li code, td code, h3 code, summary code {
background:var(--panel-2); border:1px solid var(--line); color:var(--amber-soft);
padding:.08em .4em; border-radius:4px;
}
/* ---- code blocks ---- */
.pre {
position:relative; margin:1.2rem 0; background:#0a0e14; border:1px solid var(--line);
border-radius:9px; overflow:hidden;
}
.pre::before {
content:" shell"; display:block; font-family:var(--mono); font-size:.68rem; letter-spacing:.1em;
color:var(--dim); padding:.5rem .9rem; border-bottom:1px solid var(--line); background:var(--panel);
}
.pre.is-file::before { content:"⌗ file"; }
.pre pre { margin:0; padding:1rem 1.1rem; overflow-x:auto; font-family:var(--mono); font-size:.84rem; line-height:1.6; color:var(--text); }
.pre .cm { color:var(--dim); }
.copy {
position:absolute; top:.42rem; right:.5rem; font-family:var(--mono); font-size:.68rem;
background:var(--panel-2); color:var(--muted); border:1px solid var(--line); border-radius:5px;
padding:.25rem .55rem; cursor:pointer; transition:color .15s,border-color .15s;
}
.copy:hover { color:var(--amber); border-color:var(--amber); }
.copy.ok { color:var(--green); border-color:var(--green); }
/* ---- tables ---- */
.tablewrap { overflow-x:auto; margin:1.2rem 0; border:1px solid var(--line); border-radius:9px; }
table { border-collapse:collapse; width:100%; font-size:.9rem; }
th, td { text-align:left; padding:.6rem .85rem; border-bottom:1px solid var(--line); vertical-align:top; }
thead th {
background:var(--panel); font-family:var(--mono); font-size:.7rem; letter-spacing:.08em;
text-transform:uppercase; color:var(--muted); font-weight:500;
}
tbody tr:last-child td { border-bottom:0; }
tbody tr:hover { background:rgba(255,180,84,.03); }
td:first-child code { white-space:nowrap; }
/* ---- profile cards ---- */
.profiles { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:.8rem; margin:1.4rem 0; }
.card {
background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:1rem 1.1rem;
}
.card .pname { font-family:var(--mono); font-size:.78rem; letter-spacing:.06em; color:var(--amber); text-transform:uppercase; }
.card .pdesc { font-size:.78rem; color:var(--muted); margin:.2rem 0 .9rem; }
.card .pval { display:flex; justify-content:space-between; font-family:var(--mono); font-size:.82rem; padding:.2rem 0; }
.card .pval span { color:var(--dim); }
.card .pval b { color:var(--text); font-weight:600; }
/* ---- pipeline (signature) ---- */
.pipe { list-style:none; margin:1.6rem 0 0; padding:0; counter-reset:step; position:relative; }
.pipe > li {
position:relative; padding:0 0 1.6rem 3.2rem; counter-increment:step; max-width:none;
}
.pipe > li::before {
content:counter(step,decimal-leading-zero);
position:absolute; left:0; top:0; width:2.2rem; height:2.2rem; border-radius:7px;
display:grid; place-items:center; font-family:var(--mono); font-size:.78rem; font-weight:600;
color:var(--amber); background:var(--panel); border:1px solid var(--line); z-index:1;
}
.pipe > li::after {
content:""; position:absolute; left:1.1rem; top:2.2rem; bottom:-.2rem; width:1px; background:var(--line);
}
.pipe > li:last-child::after { display:none; }
.pipe .step-title { font-weight:600; color:#e7edf4; margin:.25rem 0 .35rem; }
.pipe .step-title b { color:var(--amber-soft); }
.pipe ul { margin:.4rem 0 0; padding-left:1.1rem; }
.pipe ul li { font-size:.92rem; color:var(--text); max-width:var(--measure); }
/* ---- callouts ---- */
.note {
border-left:3px solid var(--cyan); background:rgba(86,199,232,.05);
padding:.85rem 1.1rem; border-radius:0 8px 8px 0; margin:1.2rem 0; max-width:var(--measure);
}
.note.warn { border-left-color:var(--amber); background:rgba(255,180,84,.06); }
.note .tag { font-family:var(--mono); font-size:.7rem; letter-spacing:.1em; text-transform:uppercase; color:var(--cyan); display:block; margin-bottom:.3rem; }
.note.warn .tag { color:var(--amber); }
.note p { margin:0; max-width:none; }
details.facts { margin:1.2rem 0; border:1px solid var(--line); border-radius:9px; background:var(--panel); max-width:var(--measure); }
details.facts > summary { cursor:pointer; padding:.75rem 1rem; font-weight:600; color:var(--amber-soft); list-style:none; }
details.facts > summary::-webkit-details-marker { display:none; }
details.facts > summary::before { content:"▸ "; color:var(--amber); }
details.facts[open] > summary::before { content:"▾ "; }
details.facts .body { padding:0 1rem 1rem; }
details.facts ul { margin:.3rem 0 0; }
footer { border-top:1px solid var(--line); padding:2rem clamp(1.25rem,4vw,3.5rem); color:var(--dim); font-family:var(--mono); font-size:.78rem; max-width:1180px; margin:0 auto; }
/* ---- responsive ---- */
@media (max-width:860px){
.shell { grid-template-columns:1fr; }
.toc {
position:sticky; top:0; z-index:5; border-right:0; border-bottom:1px solid var(--line);
max-height:none; padding:0; background:var(--bg);
}
.toc > summary {
display:block; cursor:pointer; padding:.9rem 1.25rem; font-family:var(--mono);
font-size:.78rem; letter-spacing:.1em; text-transform:uppercase; color:var(--amber); list-style:none;
}
.toc > summary::-webkit-details-marker { display:none; }
.toc > summary::after { content:" ▾"; color:var(--dim); }
.toc[open] > summary::after { content:" ▴"; }
.toc .toc-title { display:none; }
.toc ol { padding:0 1rem 1rem; }
}
</style>
</head>
<body>
<header class="hero">
<div class="hero-inner">
<h1 class="wordmark"><span class="prompt">$ </span>av<span class="vibe">1</span>dae</h1>
<p class="thesis">A folder-watching daemon that turns <b>.mkv</b> rips into <b>SVT-AV1 + Opus</b>, tags them with metadata from OMDb and TVmaze, and files the results — untouched by you after the drop.</p>
<div class="flow" aria-label="Processing flow">
<span class="node">drop .mkv</span><span class="arr"></span>
<span class="node">ffprobe</span><span class="arr"></span>
<span class="node">extract wav</span><span class="arr"></span>
<span class="node">opus 128k</span><span class="arr"></span>
<span class="node accent">SVT-AV1</span><span class="arr"></span>
<span class="node">mux + tag</span><span class="arr"></span>
<span class="node">output/</span>
</div>
<div class="specstrip">
<span>libsvtav1 · yuv420p10le</span>
<span>opus 128k</span>
<span>OMDb · TVmaze</span>
<span>polls every 15s</span>
<span>docker ready</span>
</div>
</div>
</header>
<div class="shell">
<details class="toc" id="toc" open>
<summary>Contents</summary>
<p class="toc-title">User Manual</p>
<ol>
<li><a href="#requirements">Requirements</a></li>
<li><a href="#build">Build</a></li>
<li><a href="#config">Configuration</a></li>
<li><a href="#running">Running</a></li>
<li><a href="#filenames">Filename convention</a></li>
<li><a href="#pipeline">Processing pipeline</a></li>
<li><a href="#naming">Output naming</a></li>
<li><a href="#logs">Logs</a></li>
<li><a href="#failures">Failure handling</a></li>
<li><a href="#polling">Polling behavior</a></li>
<li><a href="#layout">Project layout</a></li>
<li><a href="#docker">Deploy with Docker</a></li>
</ol>
</details>
<main>
<section id="requirements">
<p class="eyebrow">01 — Prerequisites</p>
<h2>Requirements</h2>
<p>These external binaries must be on <code>$PATH</code>. The program checks them at startup and exits if any are missing:</p>
<div class="tablewrap">
<table>
<thead><tr><th>Binary</th><th>Role</th></tr></thead>
<tbody>
<tr><td><code>ffmpeg</code></td><td>Video transcode + interlace detection</td></tr>
<tr><td><code>ffprobe</code></td><td>Stream + language probing</td></tr>
<tr><td><code>opusenc</code></td><td>Audio encoding</td></tr>
</tbody>
</table>
</div>
<p><strong>To build:</strong> Go 1.x with module support.</p>
<p><strong>API keys:</strong> an OMDb API key (free at <a href="https://www.omdbapi.com/">omdbapi.com</a>) is required for movie metadata. TVmaze is unauthenticated.</p>
<div class="note"><span class="tag">Running in Docker?</span><p>The three binaries above are baked into the image — the only host requirement is Docker. Skip to <a href="#docker">§12 — Deploy with Docker</a>.</p></div>
</section>
<section id="build">
<p class="eyebrow">02 — Compile</p>
<h2>Build</h2>
<div class="pre"><pre><code>go build -o av1dae ./cmd/av1dae/</code></pre></div>
<p>This produces a self-contained binary <code>./av1dae</code> (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 — <a href="#docker">§12</a>.</p>
</section>
<section id="config">
<p class="eyebrow">03 — Setup</p>
<h2>Configuration</h2>
<p>By default the config loads from <code>~/.config/av1dae/config.yaml</code>. Pass <code>-c /path/to/config.yaml</code> to override.</p>
<div class="pre is-file"><pre><code>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"</code></pre></div>
<h3>Encoding profiles</h3>
<p>Each source type carries its own SVT-AV1 quality pair. The type is chosen by a filename token, or guessed from pixel count (see <a href="#filenames">§5</a>).</p>
<div class="profiles">
<div class="card">
<div class="pname">dvd</div><div class="pdesc">SD sources</div>
<div class="pval"><span>crf</span><b>30</b></div>
<div class="pval"><span>preset</span><b>2</b></div>
</div>
<div class="card">
<div class="pname">bluray</div><div class="pdesc">HD sources</div>
<div class="pval"><span>crf</span><b>29</b></div>
<div class="pval"><span>preset</span><b>3</b></div>
</div>
<div class="card">
<div class="pname">webdl</div><div class="pdesc">Token only</div>
<div class="pval"><span>crf</span><b>30</b></div>
<div class="pval"><span>preset</span><b>3</b></div>
</div>
<div class="card">
<div class="pname">tvrip</div><div class="pdesc">Token only</div>
<div class="pval"><span>crf</span><b>32</b></div>
<div class="pval"><span>preset</span><b>2</b></div>
</div>
</div>
<h3>Field reference</h3>
<div class="tablewrap">
<table>
<thead><tr><th>Field</th><th>Meaning</th><th>Default</th></tr></thead>
<tbody>
<tr><td><code>omdb_api_key</code></td><td>OMDb key for movie metadata lookup</td><td><em>none — movies get no metadata without it</em></td></tr>
<tr><td><code>paths.input</code></td><td>Folder polled for new <code>.mkv</code> files</td><td><code>./input</code></td></tr>
<tr><td><code>paths.output</code></td><td>Destination for finished encodes</td><td><code>./output</code></td></tr>
<tr><td><code>paths.originals</code></td><td>Where sources move on success (unless <code>-d</code>)</td><td><code>./originals</code></td></tr>
<tr><td><code>paths.failed</code></td><td>Where sources go on failure</td><td><code>./failed</code></td></tr>
<tr><td><code>paths.work</code></td><td>Per-job scratch (wav/opus/output.mkv); wiped each job</td><td><code>./work</code></td></tr>
</tbody>
</table>
</div>
<div class="note"><span class="tag">Note</span><p>All five directories are created on startup if they don't already exist.</p></div>
</section>
<section id="running">
<p class="eyebrow">04 — Operate</p>
<h2>Running</h2>
<div class="pre"><pre><code>./av1dae <span class="cm"># default config, keep originals</span>
./av1dae -d <span class="cm"># delete originals after success</span>
./av1dae -c /etc/av1dae.yaml <span class="cm"># custom config</span>
./av1dae -c /etc/av1dae.yaml -d</code></pre></div>
<div class="tablewrap">
<table>
<thead><tr><th>Flag</th><th>Effect</th></tr></thead>
<tbody>
<tr><td><code>-d</code></td><td>Delete the source <code>.mkv</code> after a successful encode, instead of moving it to <code>originals/</code>.</td></tr>
<tr><td><code>-c PATH</code></td><td>Path to config file.</td></tr>
</tbody>
</table>
</div>
<div class="note warn"><span class="tag">Shutdown</span><p>Stop with <code>Ctrl+C</code> (SIGINT) or <code>SIGTERM</code>. 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 <code>.mkv</code> is routed to <code>paths.failed</code> so the next run sees a clean <code>paths.input</code>.</p></div>
<p>The program runs as a foreground daemon. It scans the input directory on startup and every 15 seconds thereafter.</p>
</section>
<section id="filenames">
<p class="eyebrow">05 — Input</p>
<h2>Filename convention</h2>
<p class="lead">The base name of each <code>.mkv</code> in <code>paths.input</code> is parsed to decide what metadata to fetch. Three forms are recognized.</p>
<h3>5.1 — Movies: IMDb ID</h3>
<p>Filename must contain an IMDb tag of the form <code>tt&lt;digits&gt;</code>.</p>
<div class="pre is-file"><pre><code>Heat.tt0113277.mkv
some-rip-tt0114369.mkv</code></pre></div>
<p>→ OMDb is queried for that IMDb ID. Title, release date, and IMDb ID are embedded.</p>
<h3>5.2 — Series: TVmaze ID + season/episode</h3>
<p>Filename must contain <strong>both</strong> <code>tvm&lt;digits&gt;</code> (the TVmaze show ID) and <code>s&lt;digits&gt;e&lt;digits&gt;</code> (season/episode). Both are case-insensitive.</p>
<div class="pre is-file"><pre><code>Breaking.Bad.tvm169.S01E01.mkv
the-wire.TVM75.s2e5.mkv</code></pre></div>
<p>→ TVmaze is queried for that show/season/episode. Show name (Collection), episode title, season, episode, airdate, and the show's IMDb ID are embedded.</p>
<h3>5.3 — No recognizable tags</h3>
<p>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 <code>.nometadata.mkv</code> suffix.</p>
<h3>5.4 — Optional: source media type</h3>
<p>Any filename can additionally carry a media-type token (case-insensitive, word-bounded): <code>dvd</code>, <code>bluray</code>, <code>webdl</code>, or <code>tvrip</code>.</p>
<div class="pre is-file"><pre><code>Heat.tt0113277.bluray.mkv
some-rip.tvm169.S01E01.webdl.mkv
old.broadcast.tvrip.tt0066026.mkv</code></pre></div>
<p>The token controls <strong>both</strong> the <code>ORIGINAL_MEDIA_TYPE</code> metadata tag and which <code>encoding.&lt;type&gt;</code> crf/preset pair is used.</p>
<div class="note warn"><span class="tag">Fallback</span><p>With no token, the type is guessed from pixel count: <code>width × height &lt; 600,000</code> → DVD, otherwise Blu-ray. <strong>WebDL and TVRip are never auto-detected</strong> — they must be declared via the token.</p></div>
</section>
<section id="pipeline">
<p class="eyebrow">06 — Signature flow</p>
<h2>The processing pipeline</h2>
<p>For every <code>.mkv</code> in <code>paths.input</code>, these steps run in order. A per-job scratch subdir under <code>paths.work</code> (named after the input base name) holds all intermediates and is deleted unconditionally at the end. Any error sends the source to <code>paths.failed</code>; the work subdir is wiped regardless.</p>
<ol class="pipe">
<li>
<p class="step-title">Parse filename</p>
Determines whether this is a movie or series, and what IDs to use.
</li>
<li>
<p class="step-title">Probe video <b>(ffprobe)</b></p>
<ul>
<li>Picks the first stream with <code>codec_type=video</code>, regardless of codec. Errors out if there isn't one.</li>
<li>Records width, height, and sample aspect ratio (SAR).</li>
<li>Detects interlacing via <code>ffmpeg -vf idet -frames:v 400 -an -sn -f null -</code>, parsing the <code>Multi frame detection</code> summary. Interlaced only when <code>TFF+BFF &gt; Progressive</code>; undetermined frames are ignored, a missing line defaults to progressive.</li>
</ul>
</li>
<li>
<p class="step-title">Probe stream languages <b>(ffprobe)</b></p>
Collects <code>language</code> tags for every audio/subtitle stream so they survive — <code>-map_metadata -1</code> strips them otherwise.
</li>
<li>
<p class="step-title">Detect media type</p>
<ul>
<li>Filename token (<code>dvd</code>/<code>bluray</code>/<code>webdl</code>/<code>tvrip</code>) wins if present.</li>
<li>Otherwise pixel count: <code>w × h &lt; 600,000</code> → DVD, else Blu-ray.</li>
</ul>
The chosen profile selects the crf/preset pair and is written to <code>ORIGINAL_MEDIA_TYPE</code>.
</li>
<li>
<p class="step-title">Fetch metadata</p>
From OMDb or TVmaze per the parsed filename. Failures here are logged but <strong>do not abort</strong> the encode — the file is just encoded without metadata.
</li>
<li>
<p class="step-title">Extract audio</p>
One PCM wav per source audio stream → <code>audio.0.wav</code>, <code>audio.1.wav</code>, … (PCM s16le, 48 kHz). Errors out if there are no audio streams.
</li>
<li>
<p class="step-title">Encode audio <b>(opusenc)</b></p>
Each wav → <code>audio.&lt;n&gt;.opus</code> at <code>--bitrate 128k</code>.
</li>
<li>
<p class="step-title">Calculate display width from SAR</p>
Rescale only when there's work to do — <code>zscale</code> is skipped for square-pixel sources (SAR <code>1:1</code>, <code>N/A</code>, empty, <code>0:N</code>) 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).
</li>
<li>
<p class="step-title">Encode video <b>(ffmpeg → libsvtav1)</b></p>
<ul>
<li>Filter chain built conditionally: <code>bwdif=mode=0:par=-1:-1</code> prepended when interlaced; <code>zscale</code> appended only when a rescale is needed. Neither → <code>-vf</code> omitted.</li>
<li>Codec <code>libsvtav1</code>, <code>-pix_fmt yuv420p10le</code>, crf/preset from the profile.</li>
<li><code>-svtav1-params film-grain=10:film-grain-denoise=1:scd=1:qm-min=4:qm-max=15:keyint=10s</code></li>
<li>Maps: video <code>0:v</code>, subtitles <code>0:s?</code> (optional), one audio per opus file (<code>1:a</code>, <code>2:a</code>, …). Audio <code>-c:a copy</code>, subtitles <code>-c:s copy</code>.</li>
<li><code>-map_metadata -1</code> strips global metadata, then language tags are re-applied — audio indexed by <strong>output position</strong>, so missing-language streams don't shift the index.</li>
<li>Container tags: <code>TITLE</code>, <code>DATE_RELEASED</code>, <code>IMDBID</code>, <code>ORIGINAL_MEDIA_TYPE</code> — plus <code>COLLECTION</code>, <code>SEASON</code>, <code>EPISODE</code>, <code>TVMAZE_ID</code> for series. Values unquoted. Output → <code>output.mkv</code> in the work dir.</li>
</ul>
</li>
<li>
<p class="step-title">Clean up the work directory</p>
Intermediates and <code>output.mkv</code> together — once the move below succeeds, or on any failure via deferred cleanup.
</li>
<li>
<p class="step-title">Rename and move to <b>paths.output</b></p>
<ul>
<li>Series with a known show name → <code>&lt;show&gt;.S&lt;NN&gt;E&lt;NN&gt;.mkv</code> (no IMDb mapping required).</li>
<li>Movie with title + IMDb ID → <code>&lt;title&gt;.&lt;imdbID&gt;.mkv</code>.</li>
<li>Anything else → <code>&lt;8-hex&gt;.nometadata.mkv</code>.</li>
</ul>
Sanitization keeps <code>a–z A–Z 0–9 - ä ö Ä Ö</code> only.
</li>
<li>
<p class="step-title">Dispose of the source</p>
<code>-d</code> set → delete the original; otherwise → move it to <code>paths.originals</code>.
</li>
</ol>
</section>
<section id="naming">
<p class="eyebrow">07 — Reference</p>
<h2>Output naming examples</h2>
<div class="tablewrap">
<table>
<thead><tr><th>Input</th><th>Result in output/</th><th>Notes</th></tr></thead>
<tbody>
<tr><td><code>Heat.tt0113277.mkv</code></td><td><code>Heat.tt0113277.mkv</code></td><td>Pixel-count fallback → Blu-ray profile + tag</td></tr>
<tr><td><code>Heat.tt0113277.bluray.mkv</code></td><td><code>Heat.tt0113277.mkv</code></td><td>Same name; <code>ORIGINAL_MEDIA_TYPE</code> now from token, not guess</td></tr>
<tr><td><code>Breaking.Bad.tvm169.S01E01.webdl.mkv</code></td><td><code>BreakingBad.S01E01.mkv</code></td><td>WebDL profile + tag</td></tr>
<tr><td><code>unrecognized-rip.mkv</code></td><td><code>a1b2c3d4.nometadata.mkv</code></td><td>No IDs at all</td></tr>
</tbody>
</table>
</div>
<details class="facts">
<summary>A few things worth knowing</summary>
<div class="body">
<ul>
<li>The media-type token affects the muxed <code>ORIGINAL_MEDIA_TYPE</code> tag and the crf/preset profile, but <strong>not</strong> the output filename.</li>
<li>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.</li>
<li>A TVmaze show with no IMDb mapping still gets a <code>&lt;Collection&gt;.S&lt;NN&gt;E&lt;NN&gt;.mkv</code> filename; only the <code>IMDBID</code> tag is left empty.</li>
</ul>
</div>
</details>
</section>
<section id="logs">
<p class="eyebrow">08 — Observability</p>
<h2>Logs</h2>
<p>Three log files are written to the <strong>current working directory</strong> (not the config paths):</p>
<div class="tablewrap">
<table>
<thead><tr><th>File</th><th>Contents</th></tr></thead>
<tbody>
<tr><td><code>info_YYYY-MM-DD.log</code></td><td>INFO messages, dated</td></tr>
<tr><td><code>error_YYYY-MM-DD.log</code></td><td>ERROR messages, dated</td></tr>
<tr><td><code>structured.json</code></td><td>One JSON object per line — every entry, with <code>timestamp</code>, <code>level</code>, <code>message</code>, optional <code>error</code> &amp; <code>file</code></td></tr>
</tbody>
</table>
</div>
<p>Run the program from the directory where you want the logs to land. A number of <code>DEBUG</code> lines also print to stdout/stderr (ffprobe output, calculated zscale width, the full ffmpeg command) — intentional, but not written to the log files.</p>
<div class="note warn"><span class="tag">Known limitation</span><p>The date in <code>info_*.log</code> / <code>error_*.log</code> 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. <code>structured.json</code> does not rotate at all.</p></div>
</section>
<section id="failures">
<p class="eyebrow">09 — Resilience</p>
<h2>Failure handling</h2>
<p>If any step from probing through encoding through renaming fails:</p>
<ul>
<li>The source <code>.mkv</code> is moved to <code>paths.failed</code> (<code>os.Stat</code>-guarded — if the source is already gone the move is skipped and logged; a move error is logged too). This guarantees the source leaves <code>paths.input</code> on every failure path, so the watcher won't retry it next tick.</li>
<li>The per-job work directory (partial wav/opus/output.mkv) is deleted unconditionally.</li>
<li>The error is logged to <code>error_*.log</code> and <code>structured.json</code> with the source path.</li>
</ul>
<p>The watcher continues with the next file; one bad rip won't stop the daemon.</p>
<div class="note"><span class="tag">Collisions</span><p>If a finished encode would land on a name that already exists in <code>paths.output</code>, the move is refused (no silent overwrite) and the source is routed to <code>paths.failed</code>. 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 <code>SxxExx</code>.</p></div>
<div class="note"><span class="tag">Cross-filesystem</span><p>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.</p></div>
</section>
<section id="polling">
<p class="eyebrow">10 — Watcher</p>
<h2>Polling behavior</h2>
<ul>
<li>Input directory is scanned every <strong>15 seconds</strong> (clamped to a 1030 s range).</li>
<li>Only <code>*.mkv</code> directly in <code>paths.input</code> are picked up — no recursion.</li>
<li>Files are processed <strong>sequentially</strong>, one at a time, in the order <code>filepath.Glob</code> returns (alphabetical on Linux).</li>
<li><strong>Partial-write protection:</strong> a file's <code>mtime</code> and <code>size</code> 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 <code>paths.input</code> is safe — no need to land it under a different name and <code>mv</code> into place (though that still works and skips the tick delay).</li>
<li><strong>Failure quarantine:</strong> if <code>processFile</code> errors, that file is skipped for 5 minutes (or until its <code>mtime</code> changes — e.g. you replace or <code>touch</code> it). Stops a permanently-broken input from spamming the logs. In-memory only; restarting clears it.</li>
</ul>
</section>
<section id="layout">
<p class="eyebrow">11 — Source map</p>
<h2>Project layout</h2>
<div class="pre is-file"><pre><code>cmd/av1dae/main.go <span class="cm">CLI entrypoint and per-file orchestration</span>
internal/config/ <span class="cm">YAML config load + defaults + mkdir</span>
internal/watcher/ <span class="cm">Polling loop, media-type detection by pixel count</span>
internal/encoder/ <span class="cm">ffprobe/ffmpeg/opusenc wrapper, transcode pipeline</span>
internal/metadata/ <span class="cm">Filename parsing, OMDb + TVmaze clients</span>
internal/mover/ <span class="cm">File rename/move/delete helpers</span>
internal/logger/ <span class="cm">Plain + JSON logging</span>
pkg/types/types.go <span class="cm">Shared structs (Config, Job, Metadata, …)</span></code></pre></div>
<p>Runtime directories (from <code>paths.*</code> in the config):</p>
<div class="pre is-file"><pre><code>paths.input <span class="cm">Drop new .mkv here; polled every 15 s</span>
paths.output <span class="cm">Finished encodes land here under their final name</span>
paths.originals <span class="cm">Encoded sources end up here (unless -d)</span>
paths.failed <span class="cm">Sources of failed jobs end up here</span>
paths.work <span class="cm">Per-job scratch subdir (basename); wiped per job</span></code></pre></div>
</section>
<section id="docker">
<p class="eyebrow">12 — Deploy</p>
<h2>Deploy with Docker</h2>
<p class="lead">Run it as a container on your server. <code>ffmpeg</code>, <code>ffprobe</code>, and <code>opusenc</code> are baked into the image, so the only host requirement is Docker — nothing to install, nothing on <code>$PATH</code>.</p>
<h3>Quick start</h3>
<div class="pre"><pre><code><span class="cm"># one-time: create the media tree and your config</span>
mkdir -p media/input media/output media/originals media/failed media/work
cp config.example.yaml config.yaml <span class="cm"># then fill in omdb_api_key</span>
docker compose up -d --build</code></pre></div>
<p>Drop <code>.mkv</code> files into <code>media/input/</code>; finished encodes appear in <code>media/output/</code>. Follow the logs with <code>docker compose logs -f</code>.</p>
<h3>How the volumes map</h3>
<p>Two mounts (defined in <code>docker-compose.yml</code>) are all it needs:</p>
<div class="tablewrap">
<table>
<thead><tr><th>Host</th><th>Container</th><th>Holds</th></tr></thead>
<tbody>
<tr><td><code>./config.yaml</code></td><td><code>/config/config.yaml</code> <em>(ro)</em></td><td>Your config — passed via <code>-c</code></td></tr>
<tr><td><code>./media</code></td><td><code>/data</code></td><td><code>input/ output/ originals/ failed/ work/</code> + <code>logs.db</code> and log files</td></tr>
</tbody>
</table>
</div>
<div class="note"><span class="tag">Paths just work</span><p>The container's working directory is <code>/data</code>, so the <strong>relative</strong> paths in <code>config.example.yaml</code> (<code>./input</code>, <code>./output</code>, …) resolve to <code>/data/input</code>, <code>/data/output</code>, … inside the mount. No path edits needed — only the <code>omdb_api_key</code>.</p></div>
<h3>What's in the image</h3>
<div class="tablewrap">
<table>
<thead><tr><th>Layer</th><th>Detail</th></tr></thead>
<tbody>
<tr><td>Build stage</td><td><code>golang:1.26-bookworm</code>, <code>CGO_ENABLED=1</code> (the SQLite logger needs cgo — no <code>scratch</code> image)</td></tr>
<tr><td>Runtime</td><td><code>debian:bookworm-slim</code></td></tr>
<tr><td>Bundled</td><td><code>ffmpeg</code> (ships <code>ffprobe</code>), <code>opus-tools</code> (<code>opusenc</code>), <code>ca-certificates</code> (for OMDb/TVmaze HTTPS)</td></tr>
</tbody>
</table>
</div>
<div class="note warn"><span class="tag">Delete originals</span><p>To delete sources after a successful encode (the <code>-d</code> flag), uncomment <code>command: ["-d"]</code> in <code>docker-compose.yml</code> and re-run <code>docker compose up -d</code>.</p></div>
<div class="note"><span class="tag">Clean shutdown</span><p><code>docker stop</code> sends <code>SIGTERM</code>, which cancels any in-flight encode and routes the source to <code>failed/</code> (see <a href="#running">§4</a>). Restart with <code>docker compose restart</code>; the daemon re-scans <code>input/</code> on boot.</p></div>
<h3>Without compose</h3>
<p>Same thing with plain <code>docker</code>:</p>
<div class="pre"><pre><code>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</code></pre></div>
</section>
</main>
</div>
<footer>av1dae — DVD/Blu-ray → SVT-AV1 transcoding daemon · single-file manual, no dependencies</footer>
<script>
// Copy buttons on every code block — native clipboard, no deps.
document.querySelectorAll('.pre').forEach(box => {
const code = box.querySelector('code');
const btn = document.createElement('button');
btn.className = 'copy'; btn.type = 'button'; btn.textContent = 'copy';
btn.setAttribute('aria-label', 'Copy code to clipboard');
btn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(code.innerText);
btn.textContent = 'copied'; btn.classList.add('ok');
setTimeout(() => { btn.textContent = 'copy'; btn.classList.remove('ok'); }, 1200);
} catch { btn.textContent = 'failed'; }
});
box.appendChild(btn);
});
// Scroll-spy: highlight the TOC entry for the section in view.
const links = [...document.querySelectorAll('.toc a')];
const byId = new Map(links.map(l => [l.getAttribute('href').slice(1), l]));
const spy = new IntersectionObserver(entries => {
entries.forEach(e => {
if (e.isIntersecting) {
links.forEach(l => l.classList.remove('active'));
byId.get(e.target.id)?.classList.add('active');
}
});
}, { rootMargin: '-15% 0px -75% 0px' });
document.querySelectorAll('section[id]').forEach(s => spy.observe(s));
// On mobile, collapse the TOC after a jump.
const toc = document.getElementById('toc');
links.forEach(l => l.addEventListener('click', () => {
if (window.matchMedia('(max-width:860px)').matches) toc.removeAttribute('open');
}));
</script>
</body>
</html>