docs: add HTML user manual

This commit is contained in:
Esa Kataja
2026-06-21 17:08:32 +03:00
parent ef52d7856c
commit 4147cb9470
+662
View File
@@ -0,0 +1,662 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>videnc·vibe — 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>videnc<span class="vibe">·vibe</span></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 videnc-vibe ./cmd/videnc/</code></pre></div>
<p>This produces a self-contained binary <code>./videnc-vibe</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/videnc-vibe/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>./videnc-vibe <span class="cm"># default config, keep originals</span>
./videnc-vibe -d <span class="cm"># delete originals after success</span>
./videnc-vibe -c /etc/videnc.yaml <span class="cm"># custom config</span>
./videnc-vibe -c /etc/videnc.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/videnc/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 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</code></pre></div>
</section>
</main>
</div>
<footer>videnc·vibe — 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>