Compare commits

...
15 Commits
Author SHA1 Message Date
Esa Kataja 31e22bd4a7 docs: describe SQLite logging (logs.db), drop stale log-file docs
Logging moved to a logs.db SQLite store, but MANUAL.md, MANUAL.html and
SPEC.md still documented info_*.log / error_*.log / structured.json and a
midnight-rollover limitation that no longer exist. Replace those sections with
the logs.db reality: levels (info/error to db+stdout/stderr, debug db-only),
progress-to-stdout-only, retention via log_retention_days, and that recent
events are viewable in the dashboard / GET /status.

Closes #6
2026-06-21 20:51:34 +03:00
Esa Kataja dfbd74b0d2 add: queue total ETA from current encode speed
The queue card now shows an estimated time to clear: current job remaining +
sum(pending source durations) / current speed. Durations come from a
path+mtime-keyed cache that probes each file once in the background (ffprobe
format=duration, header-only), so the 1s poll never re-probes; entries are
pruned to the live queue. No speed (idle/held) -> no estimate; files still
being probed mark it partial (shown as '~Xh+').

Closes #9
2026-06-21 20:48:37 +03:00
Esa Kataja 04d6c85712 add: processing controls — start/hold, pause/resume, retry
Three lifecycle controls on the dashboard, sharing one /api surface and the
status feed:

- Start/hold gate (#11): the watcher tracks the queue but holds processing
  until POST /api/start. Held by default; AV1DAE_AUTOSTART=1 restores start-
  on-boot. NOTE: flips the previous auto-start default.
- Pause/resume the active encode (#10): SIGSTOP/SIGCONT on the ffmpeg process
  (libsvtav1 is in-process, so one signal suspends all its threads). Resumes
  exactly where it left off; the tracker excludes paused time from elapsed.
- Retry a failed file (#3): POST /api/retry?file=NAME moves it from failed/
  back to input/, with a base-name guard against path traversal.

/status now reports running + the failed list; snapshots carry a paused flag.
Verified live: held queue, retry move, traversal -> 400, and a real encode
suspending to process state T on pause and S on resume.

Closes #3
Closes #10
Closes #11
2026-06-21 20:38:21 +03:00
Esa Kataja 8fc00c96b9 add: DB-backed settings UI for live-editable tunables
Add a settings page (/settings) and API (/api/settings GET/PUT) to edit
encoding profiles (crf/preset per media type), lp, OMDb key, log retention,
and delete-originals from the browser, persisted to a settings table in
logs.db. config.yaml seeds the store on first run; afterwards the DB is the
source of truth (paths.* and http_addr stay config-only). Each job snapshots
the current settings, so changes apply to the next encode with no restart.

PUT validates ranges (crf 0-63, preset 0-13, lp >=0, retention >=1) before
persisting. No auth, by design for now (isolated network) — see #2.

Closes #1
2026-06-21 19:51:35 +03:00
Esa Kataja bef58f480b fix: anchor av1dae binary gitignore to repo root
The unanchored 'av1dae' pattern (from the rename) also matched the cmd/av1dae
source dir. Anchor to /av1dae and /av1dae-dev so only the root-level built
binaries are ignored.
2026-06-21 19:39:05 +03:00
Esa Kataja 80b602fbbb add: SVT-AV1 lp (thread cap) config knob
Add encoding.lp (0 = auto) so a long encode can be capped to N logical
processors instead of pinning every core, as a lighter-weight alternative to
the compose cgroup limit. Threaded config -> types.Job -> -svtav1-params via a
testable svtav1Params helper. Settings-UI surfacing stays with #1.

refs #5
2026-06-21 19:37:26 +03:00
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
Esa Kataja a6dc0018ac add: throughput sparkline and ETA-first status hero
Restructure the current-job card around the question the monitor exists to
answer: time remaining is now the hero, with percent riding the bar. Add a
client-side throughput sparkline (90 s rolling buffer of encode speed against
a fixed 1.0x realtime reference) and a health-colored speed gauge so stalls
and sub-realtime encoding are visible at a glance. Collapse the repetitive
input queue into a count plus shared-prefix rows.
2026-06-21 18:37:29 +03:00
Esa Kataja 35193c695e add: show metadata, streams, and clock-time ETA in status UI
Surface the fetched job metadata (show/episode or movie title, season,
episode, release/airdate, media type) and the source audio/subtitle streams
through the tracker and /status, and render them in the dashboard's current-job
card. Audio channel counts are now probed and shown as 5.1/2.0/etc.

ETA now reads as a wall-clock finish time plus remaining duration, e.g.
"17:49 (3h52m)". Recent events drop the per-row date for a WhatsApp-style
Today/Yesterday/date divider with time-only rows. All dynamic strings are
HTML-escaped, since metadata and filenames are user-controlled.
2026-06-21 18:23:36 +03:00
Esa Kataja d38c3ad568 chore: expose status port in Docker/compose and document http_addr
EXPOSE 8080 in the image, publish 8080 in compose, document http_addr in the
example config, and add the CPU/memory limits so a long encode can't starve
the host.
2026-06-21 17:43:13 +03:00
Esa Kataja aa8a341069 add: live encode progress tracking and read-only status web UI
Stream ffmpeg -progress during the video encode into an in-memory tracker
(internal/status) so the long-running step is no longer a black box: percent,
fps, speed, and ETA are derived from the source duration and updated ~1/s.
Progress prints to stdout only (no logs.db row) to avoid burying real events.

Expose it over HTTP (internal/server, default :8080, set http_addr to "" to
disable): GET /status returns the live snapshot, the pending input queue, and
recent non-debug events; GET / serves an embedded dashboard that polls /status
every second. The server shuts down on the same SIGINT/SIGTERM context as the
watcher.
2026-06-21 17:43:00 +03:00
Esa Kataja 4147cb9470 docs: add HTML user manual 2026-06-21 17:08:32 +03:00
Esa Kataja ef52d7856c add: Docker image and compose for containerized deploy
Multi-stage build (cgo for the SQLite logger) on debian-slim with ffmpeg,
opus-tools, and ca-certificates bundled. Compose mounts ./data/config.yaml
and ./data/media; SIGTERM cancels in-flight encodes cleanly.
2026-06-21 17:08:32 +03:00
Esa Kataja 3170f3bbb5 chore: ignore dev binary, mp4 inputs, db files, and data dir 2026-06-21 17:08:32 +03:00
Esa Kataja f39f5b1b16 add: SQLite-backed structured logging with retention
Replace JSON/file logging with a logs.db (WAL) store. Thread the logger
through the encoder and metadata client for debug instrumentation of every
ffmpeg/ffprobe/opusenc invocation and OMDb/TVmaze request. API keys are
redacted before request URLs are logged. Retention defaults to 7 days,
overridable via log_retention_days in config.
2026-06-21 17:08:23 +03:00
27 changed files with 2961 additions and 199 deletions
+16
View File
@@ -0,0 +1,16 @@
av1dae
*.mkv
*.mp4
*.wav
*.opus
*.log
logs.db
*.json
/input
/output
/originals
/failed
/work
/data
.git
MANUAL.html
+6 -1
View File
@@ -1,11 +1,16 @@
videnc-vibe /av1dae
/av1dae-dev
*.mkv *.mkv
*.mp4
*.wav *.wav
*.opus *.opus
*.log *.log
*.json *.json
*.db
/input /input
/output /output
/originals /originals
/failed /failed
BUGS.md BUGS.md
data/*
+8 -8
View File
@@ -1,6 +1,6 @@
# AGENTS.md # AGENTS.md
This file provides guidance for agents operating in the videnc-vibe repository. This file provides guidance for agents operating in the av1dae repository.
## Project Overview ## Project Overview
@@ -10,14 +10,14 @@ Go-based CLI tool for transcoding DVD/Blu-ray to SVT-AV1 with automatic metadata
```bash ```bash
# Build binary # Build binary
go build -o videnc-vibe ./cmd/videnc/ go build -o av1dae ./cmd/av1dae/
# Run binary # Run binary
./videnc-vibe ./av1dae
# With flags # With flags
./videnc-vibe -d # Delete original after encode ./av1dae -d # Delete original after encode
./videnc-vibe -c /path/to/config.yaml ./av1dae -c /path/to/config.yaml
``` ```
## Code Style Guidelines ## Code Style Guidelines
@@ -29,7 +29,7 @@ go build -o videnc-vibe ./cmd/videnc/
### Imports ### Imports
- Group imports: standard library, external packages, internal packages - Group imports: standard library, external packages, internal packages
- Use aliases for packages: `"videnc-vibe/pkg/types"` - Use aliases for packages: `"av1dae/pkg/types"`
```go ```go
import ( import (
@@ -37,7 +37,7 @@ import (
"os" "os"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
"videnc-vibe/pkg/types" "av1dae/pkg/types"
) )
``` ```
@@ -84,7 +84,7 @@ Example: `add: add command line argument support`
## Project Structure ## Project Structure
``` ```
cmd/videnc/main.go # CLI entrypoint cmd/av1dae/main.go # CLI entrypoint
internal/ internal/
config/ # Config loading config/ # Config loading
watcher/ # Folder polling watcher/ # Folder polling
+18
View File
@@ -0,0 +1,18 @@
# Build needs cgo (the logger uses github.com/mattn/go-sqlite3), so no scratch/static image.
FROM golang:1.26-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=1 go build -ldflags="-s -w" -o /av1dae ./cmd/av1dae/
FROM debian:bookworm-slim
# ffmpeg gives ffmpeg+ffprobe; opus-tools gives opusenc; ca-certificates for OMDb/TVmaze HTTPS.
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg opus-tools ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /av1dae /usr/local/bin/av1dae
# logs.db + log files land in the working dir — make it the mounted /data so they persist.
WORKDIR /data
EXPOSE 8080
ENTRYPOINT ["av1dae", "-c", "/config/config.yaml"]
+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>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>All logs are written to a <strong>SQLite database, <code>logs.db</code></strong>, 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 <code>/data</code>. The <code>logs</code> table has columns <code>id</code>, <code>ts</code>, <code>level</code>, <code>message</code>, <code>file</code>, <code>extra</code>.</p>
<div class="tablewrap">
<table>
<thead><tr><th>Level</th><th>Where</th><th>Contents</th></tr></thead>
<tbody>
<tr><td><code>info</code></td><td>db + stdout</td><td>Processing / metadata hits / completions</td></tr>
<tr><td><code>error</code></td><td>db + stderr</td><td>Failures, with the source <code>file</code> recorded</td></tr>
<tr><td><code>debug</code></td><td>db only</td><td>ffprobe output, zscale width, full ffmpeg/opusenc command, OMDb/TVmaze responses (API key redacted) in <code>extra</code></td></tr>
</tbody>
</table>
</div>
<p>Live encode progress (<code>encoding … · 47% · 3.2fps · …</code>) prints to <strong>stdout only</strong> and is deliberately not stored, so it can't flood the database.</p>
<div class="note"><span class="tag">Retention</span><p>Rows older than <code>log_retention_days</code> (default <code>7</code>) are purged on startup and shutdown. Editable live from the settings page (<code>/settings</code>); applies at the next purge. Recent non-debug events are viewable in the dashboard and via <code>GET /status</code> when <code>http_addr</code> is set.</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>logs.db</code> (level <code>error</code>) with the source path, and printed to stderr.</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>
+19 -17
View File
@@ -1,4 +1,4 @@
# videnc-vibe — User Manual # av1dae — User Manual
A Go CLI that watches a folder for `.mkv` rips, transcodes them to SVT-AV1 video + Opus audio, embeds metadata fetched from OMDb (movies) or TVmaze (series), and files the results into output/originals/failed directories. A Go CLI that watches a folder for `.mkv` rips, transcodes them to SVT-AV1 video + Opus audio, embeds metadata fetched from OMDb (movies) or TVmaze (series), and files the results into output/originals/failed directories.
@@ -25,16 +25,16 @@ API keys:
## 2. Build ## 2. Build
```bash ```bash
go build -o videnc-vibe ./cmd/videnc/ go build -o av1dae ./cmd/av1dae/
``` ```
This produces a single static binary `./videnc-vibe`. This produces a single static binary `./av1dae`.
--- ---
## 3. Configuration ## 3. Configuration
By default the config is loaded from `~/.config/videnc-vibe/config.yaml`. Pass `-c /path/to/config.yaml` to override. By default the config is loaded from `~/.config/av1dae/config.yaml`. Pass `-c /path/to/config.yaml` to override.
Example (`config.example.yaml`): Example (`config.example.yaml`):
@@ -89,10 +89,10 @@ All five directories are created on startup if they don't exist.
## 4. Running ## 4. Running
```bash ```bash
./videnc-vibe # default config path, keep originals ./av1dae # default config path, keep originals
./videnc-vibe -d # delete originals after successful encode ./av1dae -d # delete originals after successful encode
./videnc-vibe -c /etc/videnc.yaml # custom config ./av1dae -c /etc/av1dae.yaml # custom config
./videnc-vibe -c /etc/videnc.yaml -d ./av1dae -c /etc/av1dae.yaml -d
``` ```
Flags: Flags:
@@ -225,17 +225,19 @@ A few things worth knowing:
## 8. Logs ## 8. Logs
Three log files are written to the **current working directory** (not the config paths): All logs are written to a **SQLite database, `logs.db`**, in the current working directory (not the config paths). Run the program from the directory where you want it to land — in Docker that's the mounted `/data`.
- `info_YYYY-MM-DD.log` — INFO messages, dated. The `logs` table has columns `id`, `ts`, `level`, `message`, `file`, `extra`. Three levels are recorded:
- `error_YYYY-MM-DD.log` — ERROR messages, dated.
- `structured.json` — one JSON object per line, every entry (info + error), with `timestamp`, `level`, `message`, optional `error` and `file` fields.
Run the program from the directory where you want the logs to land. - `info` — INFO messages; also printed to stdout.
- `error` — ERROR messages; also printed to stderr, with the source `file` recorded.
- `debug` — verbose diagnostics (ffprobe output, calculated zscale width, the full ffmpeg/opusenc command, OMDb/TVmaze responses with the API key redacted) in the `extra` column. **Database only** — not printed.
Note: a number of `DEBUG` lines are printed to stdout/stderr (ffprobe output, calculated zscale width, the full ffmpeg command, etc.). These are intentional but not written to the log files. Live encode progress (`encoding … · 47% · 3.2fps · …`) prints to **stdout only** and is deliberately *not* stored, so it can't flood the database.
**Known limitation:** the date in `info_*.log` / `error_*.log` filenames is computed when the daemon starts and does not roll over at midnight. If the daemon is left running across days, all writes continue into the start-day's file. Restart the daemon to rotate. `structured.json` does not rotate at all. **Retention:** rows older than `log_retention_days` (default `7`) are purged on startup and on shutdown. The value is editable live from the settings page (`/settings`) and applies at the next purge.
Recent non-debug events are also viewable in the web dashboard and via `GET /status` (when `http_addr` is set).
--- ---
@@ -245,7 +247,7 @@ If any step from probing through encoding through renaming fails:
- The source `.mkv` is moved to `paths.failed` (the move itself is `os.Stat`-guarded — if the source is already gone, the move is skipped and logged; if the move itself errors, that error is logged too). This guarantees the source leaves `paths.input` on every failure path, so the watcher doesn't retry the same file on the next tick. - The source `.mkv` is moved to `paths.failed` (the move itself is `os.Stat`-guarded — if the source is already gone, the move is skipped and logged; if the move itself errors, that error is logged too). This guarantees the source leaves `paths.input` on every failure path, so the watcher doesn't retry the same file on the next tick.
- The per-job work directory under `paths.work` (containing partial wav/opus/output.mkv) is deleted unconditionally. - The per-job work directory under `paths.work` (containing partial wav/opus/output.mkv) is deleted unconditionally.
- The error is logged to `error_*.log` and `structured.json` with the source file path. - The error is logged to `logs.db` (level `error`) with the source file path, and printed to stderr.
The watcher continues with the next file; one bad rip won't stop the daemon. The watcher continues with the next file; one bad rip won't stop the daemon.
@@ -268,7 +270,7 @@ The watcher continues with the next file; one bad rip won't stop the daemon.
## 11. Project layout ## 11. Project layout
``` ```
cmd/videnc/main.go CLI entrypoint and per-file orchestration cmd/av1dae/main.go CLI entrypoint and per-file orchestration
internal/config/ YAML config load + defaults + mkdir internal/config/ YAML config load + defaults + mkdir
internal/watcher/ Polling loop, media-type detection by pixel count internal/watcher/ Polling loop, media-type detection by pixel count
internal/encoder/ ffprobe/ffmpeg/opusenc wrapper, transcode pipeline internal/encoder/ ffprobe/ffmpeg/opusenc wrapper, transcode pipeline
+7 -6
View File
@@ -1,11 +1,11 @@
# videnc-vibe Specification # av1dae Specification
## Overview ## Overview
FFmpeg-based video transcoder for DVD/Blu-ray to SVT-AV1 with automatic metadata fetching. FFmpeg-based video transcoder for DVD/Blu-ray to SVT-AV1 with automatic metadata fetching.
## Configuration ## Configuration
Config path: `-c` flag or `~/.config/videnc-vibe/config.yaml` Config path: `-c` flag or `~/.config/av1dae/config.yaml`
```yaml ```yaml
omdb_api_key: "your-key-here" omdb_api_key: "your-key-here"
@@ -27,7 +27,7 @@ paths:
## Flags ## Flags
- `-d` : Delete original after successful encode (default: move to originals/) - `-d` : Delete original after successful encode (default: move to originals/)
- `-c` : Config file path (default: ~/.config/videnc-vibe/config.yaml) - `-c` : Config file path (default: ~/.config/av1dae/config.yaml)
## Input Parsing ## Input Parsing
@@ -99,9 +99,10 @@ Regex patterns:
## Logging ## Logging
- info.log: Human-readable info level - All logs are stored in a SQLite database `logs.db` in the working directory.
- error.log: Human-readable error level - Levels: `debug`, `info`, `error`. `info`/`error` also print to stdout/stderr; `debug` (ffprobe/ffmpeg/API dumps) is database-only. Live encode progress prints to stdout only (not stored).
- structured.json: JSON structured logs - Retention: rows older than `log_retention_days` (default 7) are purged on startup/shutdown; editable from the settings UI.
- Recent non-debug events are viewable in the web dashboard and via `GET /status`.
## Polling ## Polling
- Scan input folder every 10-30 seconds for `.mkv` files - Scan input folder every 10-30 seconds for `.mkv` files
+135 -28
View File
@@ -6,20 +6,25 @@ import (
"encoding/hex" "encoding/hex"
"flag" "flag"
"fmt" "fmt"
"net/http"
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
"syscall" "syscall"
"time"
"videnc-vibe/internal/config" "av1dae/internal/config"
"videnc-vibe/internal/encoder" "av1dae/internal/encoder"
"videnc-vibe/internal/logger" "av1dae/internal/logger"
"videnc-vibe/internal/metadata" "av1dae/internal/metadata"
"videnc-vibe/internal/mover" "av1dae/internal/mover"
"videnc-vibe/internal/watcher" "av1dae/internal/server"
"videnc-vibe/pkg/types" "av1dae/internal/settings"
"av1dae/internal/status"
"av1dae/internal/watcher"
"av1dae/pkg/types"
) )
var ( var (
@@ -29,7 +34,7 @@ var (
func init() { func init() {
flag.BoolVar(&deleteOrigin, "d", false, "Delete original after successful encode") flag.BoolVar(&deleteOrigin, "d", false, "Delete original after successful encode")
flag.StringVar(&configPath, "c", "", "Config file path (default: ~/.config/videnc-vibe/config.yaml)") flag.StringVar(&configPath, "c", "", "Config file path (default: ~/.config/av1dae/config.yaml)")
} }
func main() { func main() {
@@ -46,36 +51,108 @@ func main() {
os.Exit(1) os.Exit(1)
} }
log, err := logger.New(".") log, err := logger.New(".", cfg.LogRetentionDays)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Failed to initialize logger: %v\n", err) fmt.Fprintf(os.Stderr, "Failed to initialize logger: %v\n", err)
os.Exit(1) os.Exit(1)
} }
defer log.Close() defer log.Close()
enc := encoder.New() tracker := status.New()
enc := encoder.New(log, tracker)
if err := enc.CheckDeps(); err != nil { if err := enc.CheckDeps(); err != nil {
log.Error("Dependency check failed", err.Error()) log.Error("Dependency check failed", err.Error())
fmt.Fprintf(os.Stderr, "Dependency check failed: %v\n", err) fmt.Fprintf(os.Stderr, "Dependency check failed: %v\n", err)
os.Exit(1) os.Exit(1)
} }
metaClient := metadata.NewClient(cfg.OMDBAPIKey) // Live-editable settings: config.yaml seeds the store on first run; after
// that the DB (logs.db) is the source of truth for these values.
store, err := settings.New(log.DB(), settings.Settings{
DVD: settings.Profile{CRF: cfg.Encoding.DVD.CRF, Preset: cfg.Encoding.DVD.Preset},
Bluray: settings.Profile{CRF: cfg.Encoding.Bluray.CRF, Preset: cfg.Encoding.Bluray.Preset},
WebDL: settings.Profile{CRF: cfg.Encoding.WebDL.CRF, Preset: cfg.Encoding.WebDL.Preset},
TVRip: settings.Profile{CRF: cfg.Encoding.TVRip.CRF, Preset: cfg.Encoding.TVRip.Preset},
LP: cfg.Encoding.LP,
OMDBAPIKey: cfg.OMDBAPIKey,
LogRetentionDays: cfg.LogRetentionDays,
DeleteOriginals: deleteOrigin,
})
if err != nil {
log.Error("Settings init failed", err.Error())
fmt.Fprintf(os.Stderr, "Failed to init settings: %v\n", err)
os.Exit(1)
}
// A persisted retention value (DB) overrides the config-seeded one.
log.SetRetention(store.Get().LogRetentionDays)
metaClient := metadata.NewClient(cfg.OMDBAPIKey, log)
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel() defer cancel()
w := watcher.New(cfg.Paths.Input, 15) // Start/hold gate: held by default so the user clicks Start; AV1DAE_AUTOSTART
// (truthy) restores start-on-boot.
autostart := envTruthy(os.Getenv("AV1DAE_AUTOSTART"))
w := watcher.New(cfg.Paths.Input, 15, autostart)
if !autostart {
log.Info("Queue held on startup — click Start (set AV1DAE_AUTOSTART=1 to auto-start)")
}
controls := server.Controls{
Running: w.Running,
SetRunning: w.SetRunning,
Pause: enc.Pause,
Resume: enc.Resume,
RetryFailed: func(name string) error {
if name == "" || name != filepath.Base(name) {
return fmt.Errorf("invalid file name")
}
return mover.Rename(filepath.Join(cfg.Paths.Failed, name), filepath.Join(cfg.Paths.Input, name))
},
}
if addr := *cfg.HTTPAddr; addr != "" {
probeDuration := func(p string) (float64, error) {
pctx, pcancel := context.WithTimeout(context.Background(), 10*time.Second)
defer pcancel()
return enc.GetDuration(pctx, p)
}
srv := &http.Server{Addr: addr, Handler: server.New(tracker, log, store, controls, probeDuration, cfg.Paths.Input, cfg.Paths.Failed).Handler()}
go func() {
log.Info(fmt.Sprintf("Status server listening on %s", addr))
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Error("Status server", err.Error())
}
}()
go func() {
<-ctx.Done()
shutCtx, shutCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutCancel()
_ = srv.Shutdown(shutCtx)
}()
}
w.Start(ctx, func(ctx context.Context, inputPath string) error { w.Start(ctx, func(ctx context.Context, inputPath string) error {
return processFile(ctx, inputPath, cfg, enc, metaClient, log) return processFile(ctx, inputPath, cfg, enc, metaClient, log, tracker, store)
}) })
log.Info("videnc-vibe started") log.Info("av1dae started")
} }
func processFile(ctx context.Context, inputPath string, cfg *types.Config, enc *encoder.Encoder, metaClient *metadata.Client, log *logger.Logger) error { func processFile(ctx context.Context, inputPath string, cfg *types.Config, enc *encoder.Encoder, metaClient *metadata.Client, log *logger.Logger, tracker *status.Tracker, store *settings.Store) error {
log.Info(fmt.Sprintf("Processing: %s", inputPath)) log.Info(fmt.Sprintf("Processing: %s", inputPath))
// Snapshot the live settings once for the whole job, so a save mid-encode
// doesn't change anything until the next file.
cur := store.Get()
metaClient.SetAPIKey(cur.OMDBAPIKey)
// Mark this file as the active job (phase: probing) and clear the tracker
// back to idle on every exit path — success, failure, or cancellation.
tracker.Begin(inputPath)
defer tracker.Idle()
filename := filepath.Base(inputPath) filename := filepath.Base(inputPath)
isSeries, imdbID, tvmazeID, season, episode := metadata.ParseFilename(filename) isSeries, imdbID, tvmazeID, season, episode := metadata.ParseFilename(filename)
@@ -103,23 +180,14 @@ func processFile(ctx context.Context, inputPath string, cfg *types.Config, enc *
if err != nil { if err != nil {
log.ErrorFile(inputPath, "Getting stream languages", err.Error()) log.ErrorFile(inputPath, "Getting stream languages", err.Error())
} }
tracker.SetStreams(toStatusStreams(streamLangs))
mediaType := metadata.ParseMediaType(filename) mediaType := metadata.ParseMediaType(filename)
if mediaType == "" { if mediaType == "" {
mediaType = watcher.DetectMediaType(width, height) mediaType = watcher.DetectMediaType(width, height)
} }
var profile types.EncodingParams profile := cur.ProfileFor(mediaType)
switch mediaType {
case types.MediaTypeBluRay:
profile = cfg.Encoding.Bluray
case types.MediaTypeWebDL:
profile = cfg.Encoding.WebDL
case types.MediaTypeTVRip:
profile = cfg.Encoding.TVRip
default:
profile = cfg.Encoding.DVD
}
crf := profile.CRF crf := profile.CRF
preset := profile.Preset preset := profile.Preset
@@ -128,11 +196,15 @@ func processFile(ctx context.Context, inputPath string, cfg *types.Config, enc *
meta, err = metaClient.FetchSeriesMetadata(ctx, tvmazeID, season, episode) meta, err = metaClient.FetchSeriesMetadata(ctx, tvmazeID, season, episode)
if err != nil { if err != nil {
log.ErrorFile(inputPath, "Fetching series metadata", err.Error()) log.ErrorFile(inputPath, "Fetching series metadata", err.Error())
} else if meta != nil {
log.Info(fmt.Sprintf("TVmaze hit: %s S%sE%s - %s", meta.Collection, meta.Season, meta.Episode, meta.Title))
} }
} else if imdbID != "" { } else if imdbID != "" {
meta, err = metaClient.FetchMovieMetadata(ctx, imdbID) meta, err = metaClient.FetchMovieMetadata(ctx, imdbID)
if err != nil { if err != nil {
log.ErrorFile(inputPath, "Fetching movie metadata", err.Error()) log.ErrorFile(inputPath, "Fetching movie metadata", err.Error())
} else if meta != nil {
log.Info(fmt.Sprintf("OMDb hit: %s (%s)", meta.Title, meta.IMDBID))
} }
} }
@@ -146,12 +218,23 @@ func processFile(ctx context.Context, inputPath string, cfg *types.Config, enc *
} }
meta.OriginalMedia = mediaType meta.OriginalMedia = mediaType
tracker.SetMeta(status.JobMeta{
IsSeries: meta.IsSeries,
Title: meta.Title,
Collection: meta.Collection,
Season: meta.Season,
Episode: meta.Episode,
DateReleased: meta.DateReleased,
MediaType: string(mediaType),
})
job := &types.Job{ job := &types.Job{
InputPath: inputPath, InputPath: inputPath,
MediaType: mediaType, MediaType: mediaType,
CRF: crf, CRF: crf,
Preset: preset, Preset: preset,
DeleteOrigin: deleteOrigin, LP: cur.LP,
DeleteOrigin: cur.DeleteOriginals,
} }
if err := enc.Transcode(ctx, inputPath, workDir, job, meta, interlaced, streamLangs); err != nil { if err := enc.Transcode(ctx, inputPath, workDir, job, meta, interlaced, streamLangs); err != nil {
@@ -182,7 +265,7 @@ func processFile(ctx context.Context, inputPath string, cfg *types.Config, enc *
return err return err
} }
if deleteOrigin { if cur.DeleteOriginals {
mover.Delete(inputPath) mover.Delete(inputPath)
log.Info(fmt.Sprintf("Deleted original: %s", inputPath)) log.Info(fmt.Sprintf("Deleted original: %s", inputPath))
} else { } else {
@@ -211,6 +294,30 @@ func failToFailed(path, failedDir string, log *logger.Logger) {
} }
} }
// envTruthy reports whether an env var is set to a truthy value.
func envTruthy(s string) bool {
switch strings.ToLower(strings.TrimSpace(s)) {
case "1", "true", "yes", "on":
return true
}
return false
}
// toStatusStreams maps probed source streams to the display shape, keeping only
// audio and subtitle streams (the video stream isn't shown).
func toStatusStreams(streams []encoder.StreamMetadata) []status.Stream {
var out []status.Stream
for _, s := range streams {
switch s.CodecType {
case "audio":
out = append(out, status.Stream{Kind: "audio", Language: s.Language, Codec: s.CodecName, Channels: s.Channels, Title: s.Title})
case "subtitle":
out = append(out, status.Stream{Kind: "subtitle", Language: s.Language, Codec: s.CodecName, Title: s.Title})
}
}
return out
}
func generateRandomString(length int) string { func generateRandomString(length int) string {
bytes := make([]byte, length/2+1) bytes := make([]byte, length/2+1)
rand.Read(bytes) rand.Read(bytes)
+7
View File
@@ -1,5 +1,9 @@
omdb_api_key: "YOUR_API_KEY_HERE" omdb_api_key: "YOUR_API_KEY_HERE"
# Status server listen address. Omit for the default ":8080"; set to "" to disable.
# GET /status returns live encode progress, the input queue, and recent events.
http_addr: ":8080"
encoding: encoding:
dvd: dvd:
crf: 30 crf: 30
@@ -7,6 +11,9 @@ encoding:
bluray: bluray:
crf: 29 crf: 29
preset: 3 preset: 3
# SVT-AV1 logical-processor (thread) cap so a long encode can't pin every core.
# 0 = auto (use all cores). Applies to every profile.
lp: 0
paths: paths:
input: "./input" input: "./input"
+23
View File
@@ -0,0 +1,23 @@
services:
av1dae:
build: .
container_name: av1dae
restart: unless-stopped
# Cap CPU/RAM so a long SVT-AV1 encode can't starve the host. Tune to taste.
# `podman compose` honors deploy.resources; `podman-compose` (python) wants
# the short keys instead — cpus: 4.0 / mem_limit: 4g / cpuset: "0-3".
deploy:
resources:
limits:
cpus: "8.0"
memory: 4g
ports:
- "8080:8080" # status server + dashboard at http://host:8080 (needs http_addr: ":8080" in config)
# By default the queue is HELD on boot — click Start in the UI. Uncomment to auto-start:
# environment:
# - AV1DAE_AUTOSTART=1
volumes:
- ./data/config.yaml:/config/config.yaml:ro # your config — paths inside must point at /data/*
- ./data/media:/data # holds input/ output/ originals/ failed/ work/ + logs
# Uncomment to delete originals after a successful encode (appends -d to the entrypoint):
# command: ["-d"]
+3 -1
View File
@@ -1,5 +1,7 @@
module videnc-vibe module av1dae
go 1.26.1 go 1.26.1
require gopkg.in/yaml.v3 v3.0.1 require gopkg.in/yaml.v3 v3.0.1
require github.com/mattn/go-sqlite3 v1.14.44 // indirect
+2
View File
@@ -1,3 +1,5 @@
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+9 -2
View File
@@ -5,12 +5,12 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"av1dae/pkg/types"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
"videnc-vibe/pkg/types"
) )
const ( const (
appName = "videnc-vibe" appName = "av1dae"
configName = "config.yaml" configName = "config.yaml"
) )
@@ -76,6 +76,13 @@ func Load(configPath string) (*types.Config, error) {
if cfg.Encoding.TVRip.Preset == 0 { if cfg.Encoding.TVRip.Preset == 0 {
cfg.Encoding.TVRip.Preset = 2 cfg.Encoding.TVRip.Preset = 2
} }
if cfg.LogRetentionDays == 0 {
cfg.LogRetentionDays = 7
}
if cfg.HTTPAddr == nil {
def := ":8080"
cfg.HTTPAddr = &def
}
return &cfg, nil return &cfg, nil
} }
+250 -56
View File
@@ -1,17 +1,24 @@
package encoder package encoder
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp" "regexp"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"sync"
"syscall"
"time"
"videnc-vibe/pkg/types" "av1dae/internal/logger"
"av1dae/internal/status"
"av1dae/pkg/types"
) )
var idetSummaryRegex = regexp.MustCompile(`Multi frame detection:\s+TFF:\s*(\d+)\s+BFF:\s*(\d+)\s+Progressive:\s*(\d+)\s+Undetermined:\s*(\d+)`) var idetSummaryRegex = regexp.MustCompile(`Multi frame detection:\s+TFF:\s*(\d+)\s+BFF:\s*(\d+)\s+Progressive:\s*(\d+)\s+Undetermined:\s*(\d+)`)
@@ -20,6 +27,61 @@ type Encoder struct {
ffmpegPath string ffmpegPath string
ffprobePath string ffprobePath string
opusencPath string opusencPath string
log *logger.Logger
tracker *status.Tracker
procMu sync.Mutex
cur *os.Process // the in-flight video encode, for pause/resume
}
// Pause freezes the active video encode in place via SIGSTOP. libsvtav1 runs in
// the ffmpeg process (no forked children), so one signal suspends all its
// threads. No-op if nothing is encoding. The process keeps its memory and
// partial output and resumes exactly where it left off.
func (e *Encoder) Pause() error {
e.procMu.Lock()
defer e.procMu.Unlock()
if e.cur == nil {
return nil
}
if err := e.cur.Signal(syscall.SIGSTOP); err != nil {
return err
}
if e.tracker != nil {
e.tracker.SetPaused(true)
}
return nil
}
// Resume thaws a paused encode via SIGCONT. No-op if nothing is encoding.
func (e *Encoder) Resume() error {
e.procMu.Lock()
defer e.procMu.Unlock()
if e.cur == nil {
return nil
}
if err := e.cur.Signal(syscall.SIGCONT); err != nil {
return err
}
if e.tracker != nil {
e.tracker.SetPaused(false)
}
return nil
}
func (e *Encoder) setProc(p *os.Process) {
e.procMu.Lock()
e.cur = p
e.procMu.Unlock()
}
func (e *Encoder) clearProc() {
e.procMu.Lock()
e.cur = nil
e.procMu.Unlock()
if e.tracker != nil {
e.tracker.SetPaused(false)
}
} }
type StreamInfo struct { type StreamInfo struct {
@@ -39,13 +101,133 @@ type StreamMetadata struct {
Index int `json:"index"` Index int `json:"index"`
CodecType string `json:"codec_type"` CodecType string `json:"codec_type"`
CodecName string `json:"codec_name"` CodecName string `json:"codec_name"`
Channels int `json:"channels"`
Language string `json:"tags"` Language string `json:"tags"`
Title string `json:"title"` Title string `json:"title"`
} }
func New(log *logger.Logger, tracker *status.Tracker) *Encoder {
return &Encoder{
ffmpegPath: "ffmpeg",
ffprobePath: "ffprobe",
opusencPath: "opusenc",
log: log,
tracker: tracker,
}
}
// runCmd executes the command and emits a debug entry containing the full
// command line and its combined output. file is the source file the command
// is acting on (may be empty).
func (e *Encoder) runCmd(ctx context.Context, label, file, name string, args []string) ([]byte, error) {
cmd := exec.CommandContext(ctx, name, args...)
out, err := cmd.CombinedOutput()
extra, _ := json.Marshal(struct {
Cmd string `json:"cmd"`
Output string `json:"output"`
}{
Cmd: name + " " + strings.Join(args, " "),
Output: string(out),
})
e.log.Debug(label, file, string(extra))
return out, err
}
// runCmdProgress runs ffmpeg with `-progress pipe:1`, streaming progress
// samples to the tracker (instead of buffering all output like runCmd). stdout
// carries only the key=value progress stream; stderr carries real errors and is
// returned for the caller's error message. Used solely for the video encode —
// the one step long enough to be worth watching live.
func (e *Encoder) runCmdProgress(ctx context.Context, label, file, name string, args []string) ([]byte, error) {
cmd := exec.CommandContext(ctx, name, args...)
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
return nil, err
}
// Publish the process so Pause/Resume can signal it; clear on exit.
e.setProc(cmd.Process)
defer e.clearProc()
// Reads stdout to EOF (when ffmpeg exits), so Wait below is safe afterwards.
var lastLog time.Time
_ = status.ScanProgress(stdout, func(s status.ProgressSample) {
if e.tracker == nil {
return
}
e.tracker.Update(s.OutTimeSec, s.FPS, s.Speed)
if time.Since(lastLog) >= 5*time.Second {
lastLog = time.Now()
snap := e.tracker.Snapshot()
e.log.Progress(fmt.Sprintf("encoding %s · %.1f%% · %.1ffps · %.2fx · ETA %s",
filepath.Base(file), snap.Percent, snap.FPS, snap.Speed, fmtETA(snap.ETASec)))
}
})
err = cmd.Wait()
extra, _ := json.Marshal(struct {
Cmd string `json:"cmd"`
Output string `json:"output"`
}{
Cmd: name + " " + strings.Join(args, " "),
Output: stderr.String(),
})
e.log.Debug(label, file, string(extra))
return stderr.Bytes(), err
}
// fmtETA renders a seconds count as a compact "11h03m" / "4m12s" / "9s" string.
func fmtETA(sec int) string {
if sec <= 0 {
return "--"
}
d := time.Duration(sec) * time.Second
switch {
case d >= time.Hour:
return fmt.Sprintf("%dh%02dm", int(d.Hours()), int(d.Minutes())%60)
case d >= time.Minute:
return fmt.Sprintf("%dm%02ds", int(d.Minutes()), sec%60)
default:
return fmt.Sprintf("%ds", sec)
}
}
// GetDuration returns the source container duration in seconds via ffprobe.
func (e *Encoder) GetDuration(ctx context.Context, path string) (float64, error) {
args := []string{"-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", path}
out, err := e.runCmd(ctx, "ffprobe duration", path, e.ffprobePath, args)
if err != nil {
return 0, fmt.Errorf("ffprobe duration: %w", err)
}
d, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64)
if err != nil {
return 0, fmt.Errorf("parsing duration %q: %w", strings.TrimSpace(string(out)), err)
}
return d, nil
}
func (e *Encoder) CheckDeps() error {
if _, err := exec.LookPath(e.ffmpegPath); err != nil {
return fmt.Errorf("ffmpeg not found")
}
if _, err := exec.LookPath(e.ffprobePath); err != nil {
return fmt.Errorf("ffprobe not found")
}
if _, err := exec.LookPath(e.opusencPath); err != nil {
return fmt.Errorf("opusenc not found")
}
return nil
}
func (e *Encoder) GetStreamLanguages(ctx context.Context, path string) ([]StreamMetadata, error) { func (e *Encoder) GetStreamLanguages(ctx context.Context, path string) ([]StreamMetadata, error) {
cmd := exec.CommandContext(ctx, e.ffprobePath, "-v", "error", "-show_streams", "-print_format", "json", path) args := []string{"-v", "error", "-show_streams", "-print_format", "json", path}
output, err := cmd.CombinedOutput() output, err := e.runCmd(ctx, "ffprobe stream languages", path, e.ffprobePath, args)
if err != nil { if err != nil {
return nil, fmt.Errorf("ffprobe error: %w", err) return nil, fmt.Errorf("ffprobe error: %w", err)
} }
@@ -55,6 +237,7 @@ func (e *Encoder) GetStreamLanguages(ctx context.Context, path string) ([]Stream
Index int `json:"index"` Index int `json:"index"`
CodecType string `json:"codec_type"` CodecType string `json:"codec_type"`
CodecName string `json:"codec_name"` CodecName string `json:"codec_name"`
Channels int `json:"channels"`
Tags map[string]string `json:"tags"` Tags map[string]string `json:"tags"`
} `json:"streams"` } `json:"streams"`
} }
@@ -74,45 +257,24 @@ func (e *Encoder) GetStreamLanguages(ctx context.Context, path string) ([]Stream
Index: s.Index, Index: s.Index,
CodecType: s.CodecType, CodecType: s.CodecType,
CodecName: s.CodecName, CodecName: s.CodecName,
Channels: s.Channels,
Language: lang, Language: lang,
Title: title, Title: title,
}) })
} }
fmt.Printf("DEBUG stream languages: %+v\n", streams) streamsJSON, _ := json.Marshal(streams)
e.log.Debug("parsed stream languages", path, string(streamsJSON))
return streams, nil return streams, nil
} }
func New() *Encoder {
return &Encoder{
ffmpegPath: "ffmpeg",
ffprobePath: "ffprobe",
opusencPath: "opusenc",
}
}
func (e *Encoder) CheckDeps() error {
if _, err := exec.LookPath(e.ffmpegPath); err != nil {
return fmt.Errorf("ffmpeg not found")
}
if _, err := exec.LookPath(e.ffprobePath); err != nil {
return fmt.Errorf("ffprobe not found")
}
if _, err := exec.LookPath(e.opusencPath); err != nil {
return fmt.Errorf("opusenc not found")
}
return nil
}
func (e *Encoder) GetMediaInfo(ctx context.Context, path string) (width, height int, interlaced bool, err error) { func (e *Encoder) GetMediaInfo(ctx context.Context, path string) (width, height int, interlaced bool, err error) {
cmd := exec.CommandContext(ctx, e.ffprobePath, "-v", "error", "-show_streams", "-print_format", "json", path) probeArgs := []string{"-v", "error", "-show_streams", "-print_format", "json", path}
output, err := cmd.CombinedOutput() output, err := e.runCmd(ctx, "ffprobe media info", path, e.ffprobePath, probeArgs)
if err != nil { if err != nil {
return 0, 0, false, fmt.Errorf("ffprobe error: %w", err) return 0, 0, false, fmt.Errorf("ffprobe error: %w", err)
} }
fmt.Printf("DEBUG ffprobe output: %s\n", string(output))
var result ProbeResult var result ProbeResult
if err := json.Unmarshal(output, &result); err != nil { if err := json.Unmarshal(output, &result); err != nil {
return 0, 0, false, fmt.Errorf("parsing ffprobe output: %w", err) return 0, 0, false, fmt.Errorf("parsing ffprobe output: %w", err)
@@ -124,7 +286,10 @@ func (e *Encoder) GetMediaInfo(ctx context.Context, path string) (width, height
width = stream.Width width = stream.Width
height = stream.Height height = stream.Height
foundVideo = true foundVideo = true
fmt.Printf("DEBUG detected video: %dx%d, codec=%s, SAR=%s\n", width, height, stream.CodecName, stream.SampleAspectRatio) info, _ := json.Marshal(map[string]interface{}{
"width": width, "height": height, "codec": stream.CodecName, "sar": stream.SampleAspectRatio,
})
e.log.Debug("detected video stream", path, string(info))
break break
} }
} }
@@ -132,7 +297,7 @@ func (e *Encoder) GetMediaInfo(ctx context.Context, path string) (width, height
return 0, 0, false, fmt.Errorf("no video stream found") return 0, 0, false, fmt.Errorf("no video stream found")
} }
cmd = exec.CommandContext(ctx, e.ffmpegPath, idetArgs := []string{
"-hide_banner", "-hide_banner",
"-nostats", "-nostats",
"-i", path, "-i", path,
@@ -140,11 +305,12 @@ func (e *Encoder) GetMediaInfo(ctx context.Context, path string) (width, height
"-frames:v", "400", "-frames:v", "400",
"-an", "-sn", "-an", "-sn",
"-f", "null", "-", "-f", "null", "-",
) }
idetOut, _ := cmd.CombinedOutput() idetOut, _ := e.runCmd(ctx, "ffmpeg idet", path, e.ffmpegPath, idetArgs)
interlaced = detectInterlaced(string(idetOut)) interlaced = detectInterlaced(string(idetOut))
fmt.Printf("DEBUG interlaced: %v\n", interlaced) info, _ := json.Marshal(map[string]bool{"interlaced": interlaced})
e.log.Debug("interlace detection", path, string(info))
return width, height, interlaced, nil return width, height, interlaced, nil
} }
@@ -170,14 +336,12 @@ func detectInterlaced(idetOutput string) bool {
} }
func (e *Encoder) calculateZscaleWidth(ctx context.Context, path string, originalHeight int) (string, int, error) { func (e *Encoder) calculateZscaleWidth(ctx context.Context, path string, originalHeight int) (string, int, error) {
cmd := exec.CommandContext(ctx, e.ffprobePath, "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height,sample_aspect_ratio", "-print_format", "json", path) args := []string{"-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height,sample_aspect_ratio", "-print_format", "json", path}
output, err := cmd.CombinedOutput() output, err := e.runCmd(ctx, "ffprobe zscale info", path, e.ffprobePath, args)
if err != nil { if err != nil {
return "", 0, fmt.Errorf("ffprobe error: %w", err) return "", 0, fmt.Errorf("ffprobe error: %w", err)
} }
fmt.Printf("DEBUG ffprobe for zscale: %s\n", string(output))
var result ProbeResult var result ProbeResult
if err := json.Unmarshal(output, &result); err != nil { if err := json.Unmarshal(output, &result); err != nil {
return "", 0, fmt.Errorf("parsing ffprobe: %w", err) return "", 0, fmt.Errorf("parsing ffprobe: %w", err)
@@ -190,9 +354,7 @@ func (e *Encoder) calculateZscaleWidth(ctx context.Context, path string, origina
stream := result.Streams[0] stream := result.Streams[0]
width := stream.Width width := stream.Width
height := stream.Height height := stream.Height
sar := stream.SampleAspectRatio sar := stream.SampleAspectRatio
fmt.Printf("DEBUG stream: width=%d, height=%d, sar=%s\n", width, height, sar)
newWidth := width newWidth := width
@@ -212,7 +374,11 @@ func (e *Encoder) calculateZscaleWidth(ctx context.Context, path string, origina
// Round to nearest, then snap down to even (mod-2 widths preferred by AV1/H.264). // Round to nearest, then snap down to even (mod-2 widths preferred by AV1/H.264).
newWidth = ((width*num + den/2) / den) &^ 1 newWidth = ((width*num + den/2) / den) &^ 1
fmt.Printf("DEBUG calculated new width: %d (sar=%s)\n", newWidth, sar)
info, _ := json.Marshal(map[string]interface{}{
"width": width, "height": height, "sar": sar, "new_width": newWidth,
})
e.log.Debug("zscale calculation", path, string(info))
if newWidth == width { if newWidth == width {
return "", newWidth, nil return "", newWidth, nil
@@ -223,6 +389,9 @@ func (e *Encoder) calculateZscaleWidth(ctx context.Context, path string, origina
} }
func (e *Encoder) Transcode(ctx context.Context, input, workDir string, job *types.Job, metadata *types.Metadata, interlaced bool, streamLangs []StreamMetadata) error { func (e *Encoder) Transcode(ctx context.Context, input, workDir string, job *types.Job, metadata *types.Metadata, interlaced bool, streamLangs []StreamMetadata) error {
if e.tracker != nil {
e.tracker.SetPhase(status.PhaseAudio)
}
audioWavs, err := e.extractAudio(ctx, input, workDir, streamLangs) audioWavs, err := e.extractAudio(ctx, input, workDir, streamLangs)
if err != nil { if err != nil {
return fmt.Errorf("extracting audio: %w", err) return fmt.Errorf("extracting audio: %w", err)
@@ -263,11 +432,14 @@ func (e *Encoder) extractAudio(ctx context.Context, input, workDir string, strea
wavs := make([]string, 0, len(audio)) wavs := make([]string, 0, len(audio))
for srcAudioIndex := range audio { for srcAudioIndex := range audio {
wavPath := filepath.Join(workDir, fmt.Sprintf("audio.%d.wav", srcAudioIndex)) wavPath := filepath.Join(workDir, fmt.Sprintf("audio.%d.wav", srcAudioIndex))
cmd := exec.CommandContext(ctx, e.ffmpegPath, "-i", input, args := []string{
"-i", input,
"-map", fmt.Sprintf("0:a:%d", srcAudioIndex), "-map", fmt.Sprintf("0:a:%d", srcAudioIndex),
"-vn", "-c:a", "pcm_s16le", "-ar", "48000", "-vn", "-c:a", "pcm_s16le", "-ar", "48000",
"-f", "wav", wavPath) "-f", "wav", wavPath,
if out, err := cmd.CombinedOutput(); err != nil { }
out, err := e.runCmd(ctx, "ffmpeg extract audio", input, e.ffmpegPath, args)
if err != nil {
return wavs, fmt.Errorf("ffmpeg extract a:%d: %s %w", srcAudioIndex, out, err) return wavs, fmt.Errorf("ffmpeg extract a:%d: %s %w", srcAudioIndex, out, err)
} }
wavs = append(wavs, wavPath) wavs = append(wavs, wavPath)
@@ -280,8 +452,9 @@ func (e *Encoder) encodeOpus(ctx context.Context, wavs []string, workDir string)
for _, wav := range wavs { for _, wav := range wavs {
base := filepath.Base(strings.Replace(wav, ".wav", ".opus", 1)) base := filepath.Base(strings.Replace(wav, ".wav", ".opus", 1))
out := filepath.Join(workDir, base) out := filepath.Join(workDir, base)
cmd := exec.CommandContext(ctx, e.opusencPath, "--bitrate", "128k", wav, out) args := []string{"--bitrate", "128k", wav, out}
if logOut, err := cmd.CombinedOutput(); err != nil { logOut, err := e.runCmd(ctx, "opusenc", wav, e.opusencPath, args)
if err != nil {
return nil, fmt.Errorf("opusenc: %s %w", logOut, err) return nil, fmt.Errorf("opusenc: %s %w", logOut, err)
} }
opusFiles = append(opusFiles, out) opusFiles = append(opusFiles, out)
@@ -289,18 +462,39 @@ func (e *Encoder) encodeOpus(ctx context.Context, wavs []string, workDir string)
return opusFiles, nil return opusFiles, nil
} }
// svtav1Params builds the -svtav1-params value, appending lp=N (logical
// processors / encoder thread count) only when lp > 0. lp=0 lets SVT-AV1
// auto-detect, preserving prior behavior.
func svtav1Params(lp int) string {
p := "film-grain=10:film-grain-denoise=1:scd=1:qm-min=4:qm-max=15:keyint=10s"
if lp > 0 {
p += fmt.Sprintf(":lp=%d", lp)
}
return p
}
func (e *Encoder) encodeVideo(ctx context.Context, input, workDir string, opusFiles []string, job *types.Job, metadata *types.Metadata, interlaced bool, streamLangs []StreamMetadata) error { func (e *Encoder) encodeVideo(ctx context.Context, input, workDir string, opusFiles []string, job *types.Job, metadata *types.Metadata, interlaced bool, streamLangs []StreamMetadata) error {
outFile := filepath.Join(workDir, "output.mkv") outFile := filepath.Join(workDir, "output.mkv")
svtParams := fmt.Sprintf("film-grain=10:film-grain-denoise=1:scd=1:qm-min=4:qm-max=15:keyint=10s") // Switch the tracker to the encode phase and feed it the source duration so
// progress samples can be turned into a percentage. A failed duration probe
// just means no percent — it must not abort the encode.
if e.tracker != nil {
e.tracker.SetPhase(status.PhaseEncoding)
if dur, derr := e.GetDuration(ctx, input); derr == nil {
e.tracker.SetTotal(dur)
} else {
e.log.Debug("duration probe failed", input, derr.Error())
}
}
svtParams := svtav1Params(job.LP)
zscaleStr, newWidth, err := e.calculateZscaleWidth(ctx, input, 0) zscaleStr, newWidth, err := e.calculateZscaleWidth(ctx, input, 0)
if err != nil { if err != nil {
return fmt.Errorf("calculating zscale: %w", err) return fmt.Errorf("calculating zscale: %w", err)
} }
fmt.Printf("DEBUG zscale: %s (newWidth=%d)\n", zscaleStr, newWidth)
var filters []string var filters []string
if interlaced { if interlaced {
filters = append(filters, "bwdif=mode=0:par=-1:-1") filters = append(filters, "bwdif=mode=0:par=-1:-1")
@@ -309,7 +503,10 @@ func (e *Encoder) encodeVideo(ctx context.Context, input, workDir string, opusFi
filters = append(filters, zscaleStr) filters = append(filters, zscaleStr)
} }
fmt.Printf("DEBUG final vf: %s\n", strings.Join(filters, ",")) filterInfo, _ := json.Marshal(map[string]interface{}{
"zscale": zscaleStr, "new_width": newWidth, "interlaced": interlaced, "vf": strings.Join(filters, ","),
})
e.log.Debug("video filter chain", input, string(filterInfo))
args := []string{ args := []string{
"-y", "-y",
@@ -354,7 +551,6 @@ func (e *Encoder) encodeVideo(ctx context.Context, input, workDir string, opusFi
continue continue
} }
args = append(args, fmt.Sprintf("-metadata:s:a:%d", i), fmt.Sprintf("language=%s", lang)) args = append(args, fmt.Sprintf("-metadata:s:a:%d", i), fmt.Sprintf("language=%s", lang))
fmt.Printf("DEBUG setting audio language: stream %d -> language=%s\n", i, lang)
} }
for _, stream := range streamLangs { for _, stream := range streamLangs {
@@ -366,7 +562,6 @@ func (e *Encoder) encodeVideo(ctx context.Context, input, workDir string, opusFi
} }
} }
args = append(args, fmt.Sprintf("-metadata:s:s:%d", idx), fmt.Sprintf("language=%s", stream.Language)) args = append(args, fmt.Sprintf("-metadata:s:s:%d", idx), fmt.Sprintf("language=%s", stream.Language))
fmt.Printf("DEBUG setting subtitle language: stream %d -> language=%s\n", idx, stream.Language)
} }
} }
@@ -386,10 +581,9 @@ func (e *Encoder) encodeVideo(ctx context.Context, input, workDir string, opusFi
args = append(args, outFile) args = append(args, outFile)
args = append([]string{"-hide_banner", "-v", "error"}, args...) args = append([]string{"-hide_banner", "-v", "error", "-progress", "pipe:1", "-nostats"}, args...)
cmd := exec.CommandContext(ctx, e.ffmpegPath, args...) out, err := e.runCmdProgress(ctx, "ffmpeg encode", input, e.ffmpegPath, args)
fmt.Printf("DEBUG FFmpeg command: ffmpeg %s\n", strings.Join(args, " ")) if err != nil {
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("ffmpeg encode: %s %w", out, err) return fmt.Errorf("ffmpeg encode: %s %w", out, err)
} }
+23
View File
@@ -0,0 +1,23 @@
package encoder
import (
"strings"
"testing"
)
func TestSvtav1Params(t *testing.T) {
const base = "film-grain=10:film-grain-denoise=1:scd=1:qm-min=4:qm-max=15:keyint=10s"
if got := svtav1Params(0); got != base {
t.Errorf("lp=0 should not append lp:\n got %q\nwant %q", got, base)
}
if got := svtav1Params(-1); got != base {
t.Errorf("lp<0 should not append lp: got %q", got)
}
if got := svtav1Params(4); got != base+":lp=4" {
t.Errorf("lp=4: got %q, want suffix :lp=4", got)
}
if got := svtav1Params(4); !strings.HasPrefix(got, base) {
t.Errorf("lp set should keep the base params intact: got %q", got)
}
}
+132 -57
View File
@@ -1,94 +1,169 @@
package logger package logger
import ( import (
"encoding/json" "database/sql"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"time" "time"
"videnc-vibe/pkg/types" _ "github.com/mattn/go-sqlite3"
)
const (
LevelDebug = "debug"
LevelInfo = "info"
LevelError = "error"
) )
type Logger struct { type Logger struct {
infoPath string db *sql.DB
errorPath string dbPath string
structPath string retentionDays int
infoFile *os.File
errorFile *os.File
structFile *os.File
} }
func New(logDir string) (*Logger, error) { // New opens (or creates) logs.db inside logDir, applies the schema, and runs
// an initial retention purge. retentionDays <= 0 falls back to 7.
func New(logDir string, retentionDays int) (*Logger, error) {
if retentionDays <= 0 {
retentionDays = 7
}
if err := os.MkdirAll(logDir, 0755); err != nil { if err := os.MkdirAll(logDir, 0755); err != nil {
return nil, fmt.Errorf("creating log directory: %w", err) return nil, fmt.Errorf("creating log directory: %w", err)
} }
now := time.Now().Format("2006-01-02") dbPath := filepath.Join(logDir, "logs.db")
infoPath := filepath.Join(logDir, fmt.Sprintf("info_%s.log", now)) db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_busy_timeout=5000")
errorPath := filepath.Join(logDir, fmt.Sprintf("error_%s.log", now))
structPath := filepath.Join(logDir, "structured.json")
infoFile, err := os.OpenFile(infoPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil { if err != nil {
return nil, fmt.Errorf("opening info log: %w", err) return nil, fmt.Errorf("opening log db: %w", err)
} }
errorFile, err := os.OpenFile(errorPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) schema := `
if err != nil { CREATE TABLE IF NOT EXISTS logs (
return nil, fmt.Errorf("opening error log: %w", err) id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
level TEXT NOT NULL,
message TEXT NOT NULL,
file TEXT,
extra TEXT
);
CREATE INDEX IF NOT EXISTS idx_logs_ts_level ON logs(ts, level);
`
if _, err := db.Exec(schema); err != nil {
db.Close()
return nil, fmt.Errorf("applying log schema: %w", err)
} }
structFile, err := os.OpenFile(structPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) l := &Logger{db: db, dbPath: dbPath, retentionDays: retentionDays}
if err != nil { l.purge()
return nil, fmt.Errorf("opening structured log: %w", err) return l, nil
} }
return &Logger{ func (l *Logger) purge() {
infoPath: infoPath, cutoff := fmt.Sprintf("-%d days", l.retentionDays)
errorPath: errorPath, _, _ = l.db.Exec(`DELETE FROM logs WHERE ts < datetime('now', ?)`, cutoff)
structPath: structPath, }
infoFile: infoFile,
errorFile: errorFile, func (l *Logger) write(level, message, file, extra string) {
structFile: structFile, _, _ = l.db.Exec(
}, nil `INSERT INTO logs(level, message, file, extra) VALUES(?, ?, ?, ?)`,
level, message, nullIfEmpty(file), nullIfEmpty(extra),
)
}
func nullIfEmpty(s string) interface{} {
if s == "" {
return nil
}
return s
}
func stamp() string {
return time.Now().Format("2006-01-02 15:04:05")
} }
func (l *Logger) Info(message string) { func (l *Logger) Info(message string) {
timestamp := time.Now().Format("2006-01-02 15:04:05") fmt.Fprintf(os.Stdout, "[%s] INFO: %s\n", stamp(), message)
entry := fmt.Sprintf("[%s] INFO: %s\n", timestamp, message) l.write(LevelInfo, message, "", "")
l.infoFile.WriteString(entry)
l.writeStructured("info", message, "", "")
} }
func (l *Logger) Error(message, err string) { // Progress prints to stdout only, without a db row. For high-frequency,
timestamp := time.Now().Format("2006-01-02 15:04:05") // ephemeral telemetry (live encode progress) that would otherwise bury real
entry := fmt.Sprintf("[%s] ERROR: %s - %s\n", timestamp, message, err) // events in logs.db and churn until retention purges it.
l.errorFile.WriteString(entry) func (l *Logger) Progress(message string) {
l.writeStructured("error", message, err, "") fmt.Fprintf(os.Stdout, "[%s] INFO: %s\n", stamp(), message)
} }
func (l *Logger) ErrorFile(file string, message, err string) { func (l *Logger) Error(message, errStr string) {
timestamp := time.Now().Format("2006-01-02 15:04:05") msg := fmt.Sprintf("%s: %s", message, errStr)
entry := fmt.Sprintf("[%s] ERROR: %s - %s (file: %s)\n", timestamp, message, err, file) fmt.Fprintf(os.Stderr, "[%s] ERROR: %s\n", stamp(), msg)
l.errorFile.WriteString(entry) l.write(LevelError, msg, "", "")
l.writeStructured("error", message, err, file)
} }
func (l *Logger) writeStructured(level, message, err, file string) { func (l *Logger) ErrorFile(file, message, errStr string) {
entry := types.LogEntry{ msg := fmt.Sprintf("%s: %s", message, errStr)
Timestamp: time.Now(), fmt.Fprintf(os.Stderr, "[%s] ERROR: %s (file: %s)\n", stamp(), msg, file)
Level: level, l.write(LevelError, msg, file, "")
Message: message, }
Error: err,
File: file, // Debug records a debug entry to the database only. extra is an opaque string,
// typically JSON, used for ffprobe output, API response bodies, or command
// stdout/stderr captures. Pass "" if not applicable.
func (l *Logger) Debug(message, file, extra string) {
l.write(LevelDebug, message, file, extra)
}
// DB exposes the underlying connection so the settings store can share the
// same logs.db file rather than opening a second database.
func (l *Logger) DB() *sql.DB { return l.db }
// SetRetention updates the purge horizon at runtime (e.g. from the settings UI).
// Takes effect on the next purge. Ignored for non-positive values.
func (l *Logger) SetRetention(days int) {
if days > 0 {
l.retentionDays = days
} }
data, _ := json.Marshal(entry) }
l.structFile.WriteString(string(data) + "\n")
// LogEntry is one row returned by RecentLogs, shaped for the status feed.
type LogEntry struct {
TS string `json:"ts"`
Level string `json:"level"`
Message string `json:"message"`
File string `json:"file,omitempty"`
}
// RecentLogs returns the newest-first non-debug entries, capped at n. Debug
// rows (per-command ffprobe/ffmpeg/API dumps) are excluded — the status feed
// wants real events, not invocation noise.
func (l *Logger) RecentLogs(n int) ([]LogEntry, error) {
if l.db == nil {
return nil, nil
}
rows, err := l.db.Query(
`SELECT ts, level, message, COALESCE(file, '') FROM logs WHERE level != ? ORDER BY id DESC LIMIT ?`,
LevelDebug, n)
if err != nil {
return nil, err
}
defer rows.Close()
var out []LogEntry
for rows.Next() {
var e LogEntry
if err := rows.Scan(&e.TS, &e.Level, &e.Message, &e.File); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
} }
func (l *Logger) Close() { func (l *Logger) Close() {
l.infoFile.Close() if l.db == nil {
l.errorFile.Close() return
l.structFile.Close() }
l.purge()
l.db.Close()
l.db = nil
} }
+50 -6
View File
@@ -6,12 +6,14 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"videnc-vibe/pkg/types" "av1dae/internal/logger"
"av1dae/pkg/types"
) )
const ( const (
@@ -51,15 +53,53 @@ type TVMazeEpisode struct {
type Client struct { type Client struct {
omdbAPIKey string omdbAPIKey string
httpClient *http.Client httpClient *http.Client
log *logger.Logger
} }
func NewClient(apiKey string) *Client { func NewClient(apiKey string, log *logger.Logger) *Client {
return &Client{ return &Client{
omdbAPIKey: apiKey, omdbAPIKey: apiKey,
httpClient: &http.Client{Timeout: 10 * time.Second}, httpClient: &http.Client{Timeout: 10 * time.Second},
log: log,
} }
} }
// SetAPIKey updates the OMDb key (e.g. after a settings change). Called from the
// single-threaded encode loop before a fetch, so no locking is needed.
func (c *Client) SetAPIKey(key string) { c.omdbAPIKey = key }
// redactURL strips secret query parameters (e.g. apikey) before logging.
func redactURL(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil {
return rawURL
}
q := u.Query()
for _, k := range []string{"apikey", "api_key"} {
if q.Has(k) {
q.Set(k, "REDACTED")
}
}
u.RawQuery = q.Encode()
return u.String()
}
func (c *Client) logHTTP(label, rawURL string, status int, body []byte) {
if c.log == nil {
return
}
extra, _ := json.Marshal(struct {
URL string `json:"url"`
Status int `json:"status"`
Body string `json:"body"`
}{
URL: redactURL(rawURL),
Status: status,
Body: string(body),
})
c.log.Debug(label, "", string(extra))
}
func ParseFilename(filename string) (isSeries bool, imdbID, tvmazeID, season, episode string) { func ParseFilename(filename string) (isSeries bool, imdbID, tvmazeID, season, episode string) {
filename = strings.TrimSuffix(filename, ".mkv") filename = strings.TrimSuffix(filename, ".mkv")
@@ -111,9 +151,9 @@ func ParseMediaType(filename string) types.MediaType {
} }
func (c *Client) FetchMovieMetadata(ctx context.Context, imdbID string) (*types.Metadata, error) { func (c *Client) FetchMovieMetadata(ctx context.Context, imdbID string) (*types.Metadata, error) {
url := fmt.Sprintf("%s?i=%s&apikey=%s", omdbAPIURL, imdbID, c.omdbAPIKey) reqURL := fmt.Sprintf("%s?i=%s&apikey=%s", omdbAPIURL, imdbID, c.omdbAPIKey)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("building OMDb request: %w", err) return nil, fmt.Errorf("building OMDb request: %w", err)
} }
@@ -128,6 +168,8 @@ func (c *Client) FetchMovieMetadata(ctx context.Context, imdbID string) (*types.
return nil, fmt.Errorf("reading OMDb body: %w", err) return nil, fmt.Errorf("reading OMDb body: %w", err)
} }
c.logHTTP("OMDb GET", reqURL, resp.StatusCode, body)
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("OMDb HTTP %d: %s", resp.StatusCode, snippet(body)) return nil, fmt.Errorf("OMDb HTTP %d: %s", resp.StatusCode, snippet(body))
} }
@@ -197,8 +239,8 @@ func (c *Client) FetchSeriesMetadata(ctx context.Context, tvmazeID, season, epis
}, nil }, nil
} }
func (c *Client) fetchTVMazeJSON(ctx context.Context, url string, v interface{}) error { func (c *Client) fetchTVMazeJSON(ctx context.Context, reqURL string, v interface{}) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil { if err != nil {
return fmt.Errorf("build request: %w", err) return fmt.Errorf("build request: %w", err)
} }
@@ -213,6 +255,8 @@ func (c *Client) fetchTVMazeJSON(ctx context.Context, url string, v interface{})
return fmt.Errorf("read body: %w", err) return fmt.Errorf("read body: %w", err)
} }
c.logHTTP("TVmaze GET", reqURL, resp.StatusCode, body)
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, snippet(body)) return fmt.Errorf("HTTP %d: %s", resp.StatusCode, snippet(body))
} }
+451
View File
@@ -0,0 +1,451 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>av1dae — status</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;
}
* { box-sizing:border-box; }
body {
margin:0; background:var(--bg); color:var(--text);
font-family:var(--sans); font-size:16px; line-height:1.5;
-webkit-font-smoothing:antialiased;
padding:clamp(1.25rem,4vw,2.5rem); max-width:920px; margin:0 auto;
}
a { color:var(--cyan); }
code { font-family:var(--mono); }
/* header */
header { display:flex; align-items:center; gap:1rem; margin-bottom:1.75rem; }
.wordmark { font-family:var(--mono); font-weight:600; font-size:1.4rem; letter-spacing:-.02em; }
.wordmark .prompt { color:var(--dim); }
.wordmark .vibe { color:var(--amber); }
.navlink { font-family:var(--mono); font-size:.78rem; color:var(--muted); text-decoration:none; }
.navlink:hover { color:var(--amber); }
.live { margin-left:auto; display:flex; align-items:center; gap:.5rem; font-family:var(--mono); font-size:.74rem; color:var(--muted); text-transform:uppercase; letter-spacing:.1em; }
.dot { width:9px; height:9px; border-radius:50%; background:var(--dim); }
.dot.ok { background:var(--green); box-shadow:0 0 0 0 rgba(92,201,139,.6); animation:pulse 2s infinite; }
.dot.idle { background:var(--amber); }
.dot.err { background:var(--red); }
@keyframes pulse { 0%{box-shadow:0 0 0 0 rgba(92,201,139,.5);} 70%{box-shadow:0 0 0 7px rgba(92,201,139,0);} 100%{box-shadow:0 0 0 0 rgba(92,201,139,0);} }
.card { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:1.4rem 1.5rem; margin-bottom:1.25rem; }
.eyebrow { font-family:var(--mono); font-size:.7rem; letter-spacing:.16em; text-transform:uppercase; color:var(--dim); margin:0 0 .9rem; }
/* ---- hero ---- */
.job-head { display:flex; align-items:baseline; gap:.6rem; flex-wrap:wrap; margin-bottom:1rem; }
.job-title { font-size:1.2rem; font-weight:600; color:#e7edf4; word-break:break-word; }
.job-title .se { color:var(--amber); font-family:var(--mono); font-size:1rem; margin-right:.45rem; }
.job-title .ep { color:var(--muted); font-weight:400; }
.badge { font-family:var(--mono); font-size:.66rem; letter-spacing:.08em; text-transform:uppercase; padding:.2rem .5rem; border-radius:5px; border:1px solid var(--line); color:var(--muted); margin-left:auto; white-space:nowrap; }
.badge[data-phase="encoding"] { color:var(--amber); border-color:var(--amber); }
.badge[data-phase="probing"], .badge[data-phase="audio"] { color:var(--cyan); border-color:var(--cyan); }
/* ETA is the hero answer */
.eta-line { display:flex; align-items:baseline; gap:.7rem; flex-wrap:wrap; margin-bottom:.9rem; }
.eta-big { font-family:var(--mono); font-size:2.6rem; font-weight:600; line-height:1; color:var(--amber-soft); }
.eta-big.dim { color:var(--dim); font-size:1.6rem; }
.eta-at { font-family:var(--mono); color:var(--muted); font-size:.95rem; }
.eta-at b { color:var(--text); font-weight:500; }
.bar { position:relative; height:12px; border-radius:6px; background:var(--panel-2); overflow:hidden; border:1px solid var(--line); }
.bar > i { display:block; height:100%; width:0; background:linear-gradient(90deg,var(--amber-soft),var(--amber)); border-radius:6px; transition:width .6s ease; }
.bar.indet > i { width:35%; background:linear-gradient(90deg,transparent,var(--cyan),transparent); animation:slide 1.3s linear infinite; transition:none; }
@keyframes slide { 0%{transform:translateX(-120%);} 100%{transform:translateX(340%);} }
.pct { font-family:var(--mono); font-size:.8rem; color:var(--muted); margin-top:.45rem; }
.pct b { color:var(--text); }
.idle-msg { color:var(--muted); }
/* ---- signature: throughput sparkline ---- */
.pulse { margin-top:1.3rem; padding-top:1.2rem; border-top:1px solid var(--line); display:grid; grid-template-columns:1fr auto; gap:1rem 1.5rem; align-items:center; }
.spark-wrap { grid-column:1; min-width:0; }
.spark-head { display:flex; justify-content:space-between; align-items:baseline; font-family:var(--mono); font-size:.66rem; letter-spacing:.1em; text-transform:uppercase; color:var(--dim); margin-bottom:.35rem; }
svg.spark { width:100%; height:54px; display:block; overflow:visible; }
.ref-label { font-family:var(--mono); font-size:.6rem; fill:var(--dim); }
.gauges { grid-column:2; display:flex; gap:1.6rem; }
.gauge { font-family:var(--mono); text-align:right; }
.gauge .v { font-size:1.5rem; line-height:1; }
.gauge .v.warn { color:var(--amber); }
.gauge .v.bad { color:var(--red); }
.gauge .v.good { color:var(--green); }
.gauge .k { font-size:.62rem; letter-spacing:.12em; text-transform:uppercase; color:var(--dim); margin-top:.35rem; }
.gauge .sub { font-size:.6rem; color:var(--dim); margin-top:.15rem; }
.substats { display:flex; flex-wrap:wrap; gap:1.4rem; margin-top:1.2rem; font-family:var(--mono); }
.substats .s { font-size:.8rem; color:var(--muted); }
.substats .s b { color:var(--text); font-weight:500; }
/* stream chips */
.streams { display:flex; flex-wrap:wrap; gap:.5rem; margin-top:1rem; }
.chip { font-family:var(--mono); font-size:.72rem; padding:.2rem .5rem; border-radius:5px; background:var(--panel-2); border:1px solid var(--line); color:var(--text); }
.chip .lab { color:var(--dim); }
.chip .ch { color:var(--amber); }
/* grid */
.grid { display:grid; grid-template-columns:1fr 1fr; gap:1.25rem; }
@media (max-width:680px){ .grid { grid-template-columns:1fr; } .pulse{ grid-template-columns:1fr; } .gauges{ grid-column:1; justify-content:flex-start; } .gauge{ text-align:left; } }
/* queue — collapsed aggregate */
.qsum { display:flex; align-items:baseline; gap:.5rem; flex-wrap:wrap; margin-bottom:.8rem; }
.qsum .qn { font-family:var(--mono); font-size:1.4rem; color:var(--amber); line-height:1; }
.qsum .ql { color:var(--muted); font-size:.85rem; }
.qsum .qeta { font-family:var(--mono); font-size:.8rem; color:var(--amber-soft); margin-left:auto; }
ul.list { list-style:none; margin:0; padding:0; font-family:var(--mono); font-size:.82rem; }
ul.list li { padding:.35rem 0; border-bottom:1px solid var(--line); color:var(--muted); word-break:break-word; }
ul.list li:last-child { border-bottom:0; }
ul.list li .pre { color:var(--dim); }
ul.list li .key { color:var(--text); }
.qmore { font-family:var(--mono); font-size:.74rem; color:var(--dim); padding-top:.5rem; }
/* events */
.ev { display:flex; gap:.6rem; padding:.4rem 0; border-bottom:1px solid var(--line); font-size:.82rem; }
.ev:last-child { border-bottom:0; }
.ev time { font-family:var(--mono); font-size:.7rem; color:var(--dim); white-space:nowrap; padding-top:.1rem; }
.ev .msg { color:var(--text); word-break:break-word; }
.ev.error .msg { color:var(--red); }
.day { text-align:center; font-family:var(--mono); font-size:.64rem; letter-spacing:.12em; text-transform:uppercase; color:var(--dim); margin:.8rem 0 .2rem; }
.day:first-child { margin-top:0; }
@media (prefers-reduced-motion:reduce){ .dot.ok{animation:none;} .bar.indet>i{animation:none; width:100%; opacity:.4;} }
/* control bar */
.controls { display:flex; align-items:center; gap:.6rem; flex-wrap:wrap; margin-bottom:1.25rem; min-height:1px; }
.ctl { font-family:var(--mono); font-size:.82rem; cursor:pointer; border-radius:7px; padding:.45rem .9rem;
background:var(--panel-2); color:var(--text); border:1px solid var(--line); }
.ctl:hover { border-color:var(--amber); color:var(--amber); }
.ctl.primary { background:var(--amber); color:#1a1200; border-color:var(--amber); font-weight:600; }
.ctl.primary:hover { background:var(--amber-soft); color:#1a1200; }
.ctl.small { font-size:.72rem; padding:.25rem .65rem; }
.ctl-state { font-family:var(--mono); font-size:.72rem; color:var(--muted); margin-left:.3rem; text-transform:uppercase; letter-spacing:.08em; }
.ctl-state.paused { color:var(--amber); }
.count { font-family:var(--mono); color:var(--red); }
/* failed list */
#failed .frow { display:flex; align-items:center; gap:.6rem; padding:.35rem 0; border-bottom:1px solid var(--line); font-family:var(--mono); font-size:.82rem; }
#failed .frow:last-child { border-bottom:0; }
#failed .frow .fn { flex:1; color:var(--text); word-break:break-word; }
</style>
</head>
<body>
<header>
<span class="wordmark"><span class="prompt">$ </span>av<span class="vibe">1</span>dae</span>
<a href="/settings" class="navlink">settings</a>
<span class="live"><span class="dot" id="dot"></span><span id="livetext">connecting</span></span>
</header>
<div class="controls" id="controls"></div>
<section class="card" id="job">
<div id="job-body"></div>
</section>
<section class="card" id="failedCard" hidden>
<p class="eyebrow">Failed <span class="count" id="fcount"></span></p>
<div id="failed"></div>
</section>
<div class="grid">
<section class="card">
<p class="eyebrow">Queue</p>
<div id="queue"></div>
</section>
<section class="card">
<p class="eyebrow">Recent events</p>
<div id="events"></div>
</section>
</div>
<script>
const $ = id => document.getElementById(id);
// Metadata, filenames, and event messages all originate from user-supplied
// filenames — escape every dynamic string before it touches innerHTML.
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"']/g, c =>
({ "&":"&amp;", "<":"&lt;", ">":"&gt;", '"':"&quot;", "'":"&#39;" }[c]));
}
function base(path) { return (path || "").split("/").pop(); }
function pad2(s) { return String(s || "").padStart(2, "0"); }
// "3h 53m" / "53m 20s" / "20s" — remaining time, spelled for the hero.
function fmtDurLong(sec) {
sec = Math.max(0, Math.floor(sec || 0));
if (sec <= 0) return "—";
const h = Math.floor(sec / 3600), m = Math.floor(sec % 3600 / 60), s = sec % 60;
if (h) return `${h}h ${pad2(m)}m`;
if (m) return `${m}m ${pad2(s)}s`;
return `${s}s`;
}
// wall-clock finish time, "21:35"
function clockAt(sec) {
if (!sec || sec <= 0) return "—";
const d = new Date(Date.now() + sec * 1000), p = n => String(n).padStart(2, "0");
return `${p(d.getHours())}:${p(d.getMinutes())}`;
}
function chLabel(n) { return ({1:"mono",2:"2.0",6:"5.1",8:"7.1"})[n] || (n ? n+"ch" : ""); }
// ---- signature: throughput sparkline, fed by the 1 s poll ----
let speedHist = [], histFile = null;
function pushSpeed(c) {
if (!c || c.file !== histFile) { speedHist = []; histFile = c ? c.file : null; }
if (c && c.phase === "encoding" && c.speed > 0) {
speedHist.push(c.speed);
if (speedHist.length > 90) speedHist.shift(); // ~90 s window
}
}
// Trace of recent speed against a fixed 1.0× realtime reference. The reference
// stays fixed on purpose — autoscaling would hide how far below realtime we run.
function sparkSVG(hist, ref) {
const W = 300, H = 54;
if (hist.length < 2)
return `<svg class="spark" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" aria-hidden="true"></svg>`;
const max = Math.max(ref * 1.3, ...hist);
const y = v => H - (v / max) * H;
const pts = hist.map((v, i) => `${(i/(hist.length-1)*W).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
const refY = y(ref).toFixed(1);
const below = hist[hist.length-1] < ref;
const col = below ? "var(--red)" : "var(--green)";
return `<svg class="spark" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" aria-hidden="true">
<line x1="0" y1="${refY}" x2="${W}" y2="${refY}" stroke="var(--dim)" stroke-width="1" stroke-dasharray="3 3"/>
<text class="ref-label" x="2" y="${Math.max(8, refY-3)}">${ref.toFixed(1)}×</text>
<polyline fill="none" stroke="${col}" stroke-width="2" stroke-linejoin="round" points="${pts}"/>
<circle cx="${W}" cy="${y(hist[hist.length-1]).toFixed(1)}" r="2.5" fill="${col}"/>
</svg>`;
}
function trendLabel(hist) {
if (hist.length < 4) return ["—", "var(--dim)"];
const last = hist[hist.length-1], prev = hist[hist.length-3];
if (last < prev - 0.01) return ["slowing", "var(--red)"];
if (last > prev + 0.01) return ["recovering", "var(--green)"];
return ["holding", "var(--dim)"];
}
function speedClass(v) { return v <= 0 ? "" : v < 0.5 ? "bad" : v < 1 ? "warn" : "good"; }
function jobTitleHTML(c) {
const m = c.meta;
if (m && m.is_series && m.collection) {
const se = (m.season && m.episode) ? `S${pad2(m.season)}E${pad2(m.episode)}` : "";
const ep = (m.title && m.title !== "Unknown") ? ` — “${esc(m.title)}` : "";
return `${se ? `<span class="se">${se}</span>` : ""}${esc(m.collection)}<span class="ep">${ep}</span>`;
}
if (m && m.title && m.title !== "Unknown") return esc(m.title);
return esc(base(c.file));
}
function streamsHTML(streams) {
if (!streams || !streams.length) return "";
const chips = streams.filter(s => s.kind === "audio" || s.kind === "subtitle").map(s => {
const lab = s.kind === "audio" ? "audio" : "subs";
const ch = s.kind === "audio" ? chLabel(s.channels) : "";
const codec = s.kind === "audio" ? esc(s.codec || "") : "";
return `<span class="chip"><span class="lab">${lab}</span> ${esc(s.language || "und")}` +
`${ch ? ` <span class="ch">${ch}</span>` : ""}${codec ? ` ${codec}` : ""}</span>`;
}).join("");
return chips ? `<div class="streams">${chips}</div>` : "";
}
function substatsHTML(c) {
const m = c.meta, parts = [];
if (c.elapsed_sec) parts.push(`elapsed <b>${fmtDurLong(c.elapsed_sec)}</b>`);
if (m && m.media_type) parts.push(`source <b>${esc(m.media_type)}</b>`);
if (m && m.date_released) parts.push(`${m.is_series ? "aired" : "released"} <b>${esc(m.date_released)}</b>`);
return parts.length ? `<div class="substats">${parts.map(p => `<div class="s">${p}</div>`).join("")}</div>` : "";
}
function renderJob(c) {
const body = $("job-body");
if (!c || c.phase === "idle" || !c.file) {
body.innerHTML = `<div class="eta-line"><span class="eta-big dim">Idle</span></div>
<p class="idle-msg">Waiting for files in <code>input/</code>.</p>`;
return;
}
const encoding = c.phase === "encoding" && c.percent > 0;
// Preparing phases (probing/audio) have no progress yet — indeterminate bar.
if (!encoding) {
body.innerHTML = `
<div class="job-head">
<span class="job-title">${jobTitleHTML(c)}</span>
<span class="badge" data-phase="${esc(c.phase)}">${esc(c.phase)}</span>
</div>
<div class="eta-line"><span class="eta-big dim">preparing…</span></div>
<div class="bar indet" role="progressbar" aria-valuetext="preparing"><i></i></div>
${substatsHTML(c)}${streamsHTML(c.streams)}`;
return;
}
const sp = c.speed || 0, [trend, trendCol] = trendLabel(speedHist);
body.innerHTML = `
<div class="job-head">
<span class="job-title">${jobTitleHTML(c)}</span>
<span class="badge" data-phase="encoding">encoding</span>
</div>
<div class="eta-line">
<span class="eta-big">${fmtDurLong(c.eta_sec)}</span>
<span class="eta-at">left · done ~<b>${clockAt(c.eta_sec)}</b></span>
</div>
<div class="bar" role="progressbar" aria-valuenow="${Math.round(c.percent)}" aria-valuemin="0" aria-valuemax="100"><i style="width:${c.percent}%"></i></div>
<div class="pct"><b>${c.percent.toFixed(1)}%</b> encoded</div>
<div class="pulse">
<div class="spark-wrap">
<div class="spark-head"><span>throughput · last 90s</span><span style="color:${trendCol}">${trend}</span></div>
${sparkSVG(speedHist, 1.0)}
</div>
<div class="gauges">
<div class="gauge">
<div class="v ${speedClass(sp)}">${sp.toFixed(2)}×</div>
<div class="k">speed</div>
<div class="sub">${sp > 0 && sp < 1 ? "below realtime" : sp >= 1 ? "realtime+" : ""}</div>
</div>
<div class="gauge">
<div class="v">${(c.fps || 0).toFixed(1)}</div>
<div class="k">fps</div>
</div>
</div>
</div>
${substatsHTML(c)}${streamsHTML(c.streams)}`;
}
// Longest common prefix, so a queue of "Medium S05E02/03/04…" shows the
// shared part once and the differing tail per row.
function commonPrefix(arr) {
if (!arr.length) return "";
let p = arr[0];
for (const s of arr) { while (!s.startsWith(p)) p = p.slice(0, -1); if (!p) break; }
return p;
}
function renderQueue(d) {
const queue = d.queue, current = d.current;
const cur = base(current && current.file);
const pending = (queue || []).filter(f => f !== cur);
const el = $("queue");
if (!pending.length) {
el.innerHTML = `<div class="qsum"><span class="ql">Nothing waiting</span></div>`;
return;
}
const pre = pending.length > 1 ? commonPrefix(pending) : "";
const eta = d.queue_eta_sec > 0
? `<span class="qeta">~${fmtDurLong(d.queue_eta_sec)}${d.queue_eta_partial ? "+" : ""} to clear</span>`
: "";
const head = `<div class="qsum"><span class="qn">${pending.length}</span><span class="ql">${pending.length === 1 ? "file" : "files"} waiting</span>${eta}</div>`;
const rows = pending.slice(0, 3).map(f => {
const tail = pre ? f.slice(pre.length) : f;
return `<li>${pre ? `<span class="pre">${esc(pre)}</span>` : ""}<span class="key">${esc(tail)}</span></li>`;
}).join("");
const more = pending.length > 3 ? `<div class="qmore">+ ${pending.length - 3} more</div>` : "";
el.innerHTML = head + `<ul class="list">${rows}</ul>` + more;
}
// WhatsApp-style: a date divider whenever the day changes, time-only per row.
function dayLabel(dt) {
const t = new Date(), a = new Date(t.getFullYear(), t.getMonth(), t.getDate());
const b = new Date(dt.getFullYear(), dt.getMonth(), dt.getDate());
const diff = Math.round((a - b) / 86400000);
if (diff === 0) return "Today";
if (diff === 1) return "Yesterday";
return dt.toLocaleDateString(undefined, { year:"numeric", month:"short", day:"numeric" });
}
function hms(dt) {
const p = n => String(n).padStart(2, "0");
return `${p(dt.getHours())}:${p(dt.getMinutes())}:${p(dt.getSeconds())}`;
}
function renderEvents(recent) {
if (!recent || !recent.length) {
$("events").innerHTML = `<div class="ev"><span class="msg" style="color:var(--dim)">No events yet</span></div>`;
return;
}
let html = "", lastDay = null;
for (const e of recent) {
const dt = new Date(e.ts);
const day = dayLabel(dt);
if (day !== lastDay) { html += `<div class="day">${esc(day)}</div>`; lastDay = day; }
const cls = e.level === "error" ? "ev error" : "ev";
html += `<div class="${cls}"><time>${hms(dt)}</time><span class="msg">${esc(e.message)}</span></div>`;
}
$("events").innerHTML = html;
}
function setLive(state) {
const dot = $("dot"), txt = $("livetext");
dot.className = "dot " + state;
txt.textContent = state === "ok" ? "live" : state === "idle" ? "idle" : state === "err" ? "offline" : "connecting";
}
// ---- runtime controls: start/hold, pause/resume, retry ----
async function post(path) {
try { await fetch(path, { method: "POST" }); } catch (e) {}
poll(); // reflect the new state immediately
}
function renderControls(d) {
const c = d.current || {};
const encoding = c.file && c.phase !== "idle";
let html = d.running
? `<button class="ctl" data-act="/api/hold">⏸ Hold queue</button>`
: `<button class="ctl primary" data-act="/api/start">▶ Start queue</button>`;
if (encoding) {
html += c.paused
? `<button class="ctl primary" data-act="/api/resume">▶ Resume encode</button>`
: `<button class="ctl" data-act="/api/pause">⏸ Pause encode</button>`;
}
const state = !d.running ? "held" : c.paused ? "running · encode paused" : "running";
html += `<span class="ctl-state${c.paused ? " paused" : ""}">${state}</span>`;
$("controls").innerHTML = html;
}
function renderFailed(failed) {
const card = $("failedCard");
if (!failed || !failed.length) { card.hidden = true; $("failed").innerHTML = ""; return; }
card.hidden = false;
$("fcount").textContent = "(" + failed.length + ")";
$("failed").innerHTML = failed.map(f =>
`<div class="frow"><span class="fn">${esc(f)}</span><button class="ctl small" data-retry="${esc(f)}">retry</button></div>`
).join("");
}
$("controls").addEventListener("click", e => {
const b = e.target.closest("[data-act]");
if (b) post(b.getAttribute("data-act"));
});
$("failed").addEventListener("click", e => {
const b = e.target.closest("[data-retry]");
if (b) post("/api/retry?file=" + encodeURIComponent(b.getAttribute("data-retry")));
});
async function poll() {
try {
const r = await fetch("status", { cache: "no-store" });
if (!r.ok) throw new Error(r.status);
const d = await r.json();
pushSpeed(d.current);
renderControls(d);
renderJob(d.current);
renderFailed(d.failed);
renderQueue(d);
renderEvents(d.recent);
const active = d.current && d.current.file && d.current.phase !== "idle";
setLive(d.current && d.current.paused ? "idle" : active ? "ok" : "idle");
} catch (e) {
setLive("err");
} finally {
setTimeout(poll, 1000);
}
}
poll();
</script>
</body>
</html>
+300
View File
@@ -0,0 +1,300 @@
// Package server exposes a read-only HTTP status endpoint for the daemon:
// the live encode progress, the pending input queue, and recent log events.
package server
import (
_ "embed"
"encoding/json"
"net/http"
"os"
"path/filepath"
"sync"
"time"
"av1dae/internal/logger"
"av1dae/internal/settings"
"av1dae/internal/status"
"av1dae/internal/watcher"
)
//go:embed index.html
var indexHTML []byte
//go:embed settings.html
var settingsHTML []byte
// Controls is the runtime-control surface the UI drives, wired in main from the
// watcher (start/hold gate), encoder (pause/resume), and mover (retry).
type Controls struct {
Running func() bool
SetRunning func(bool)
Pause func() error
Resume func() error
RetryFailed func(name string) error
}
type Server struct {
tracker *status.Tracker
log *logger.Logger
store *settings.Store
controls Controls
durCache *durationCache
inputDir string
failedDir string
}
func New(tracker *status.Tracker, log *logger.Logger, store *settings.Store, controls Controls, probeDuration func(string) (float64, error), inputDir, failedDir string) *Server {
return &Server{
tracker: tracker,
log: log,
store: store,
controls: controls,
durCache: newDurationCache(probeDuration),
inputDir: inputDir,
failedDir: failedDir,
}
}
type durEntry struct {
mtime time.Time
sec float64
}
// durationCache memoizes source durations (keyed by path+mtime) so the queue
// ETA doesn't re-probe every file on every 1s poll. A miss kicks a background
// ffprobe and resolves on a later poll; the file shows as "estimating" until.
type durationCache struct {
probe func(string) (float64, error)
mu sync.Mutex
m map[string]durEntry
inflight map[string]bool
}
func newDurationCache(probe func(string) (float64, error)) *durationCache {
return &durationCache{probe: probe, m: map[string]durEntry{}, inflight: map[string]bool{}}
}
func (c *durationCache) Get(path string, mtime time.Time) (float64, bool) {
c.mu.Lock()
if e, ok := c.m[path]; ok && e.mtime.Equal(mtime) {
c.mu.Unlock()
return e.sec, true
}
if c.inflight[path] {
c.mu.Unlock()
return 0, false
}
c.inflight[path] = true
c.mu.Unlock()
go func() {
sec, err := c.probe(path)
c.mu.Lock()
delete(c.inflight, path)
if err == nil {
c.m[path] = durEntry{mtime, sec}
}
c.mu.Unlock()
}()
return 0, false
}
// Retain drops cached entries for paths no longer present, bounding the map on
// a long-running daemon.
func (c *durationCache) Retain(paths []string) {
keep := make(map[string]bool, len(paths))
for _, p := range paths {
keep[p] = true
}
c.mu.Lock()
for p := range c.m {
if !keep[p] {
delete(c.m, p)
}
}
c.mu.Unlock()
}
// queueETASeconds estimates wall-clock time to clear the queue: the current
// job's remaining time plus each pending file's duration / current speed.
// 0 when there's no speed to extrapolate from (idle/held).
func queueETASeconds(currentETASec int, sumQueuedSec, speed float64) int {
if speed <= 0 {
return 0
}
return currentETASec + int(sumQueuedSec/speed)
}
// Handler returns the mux for all status routes. Phase 3 adds "/" (the HTML
// dashboard) to this same mux.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/status", s.handleStatus)
mux.HandleFunc("/settings", s.handleSettingsPage)
mux.HandleFunc("/api/settings", s.handleAPISettings)
mux.HandleFunc("/api/start", s.gateHandler(true))
mux.HandleFunc("/api/hold", s.gateHandler(false))
mux.HandleFunc("/api/pause", s.actionHandler(func() error { return s.controls.Pause() }, "Encode paused"))
mux.HandleFunc("/api/resume", s.actionHandler(func() error { return s.controls.Resume() }, "Encode resumed"))
mux.HandleFunc("/api/retry", s.handleRetry)
mux.HandleFunc("/", s.handleIndex)
return mux
}
// gateHandler flips the start/hold gate. POST only.
func (s *Server) gateHandler(run bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w)
return
}
s.controls.SetRunning(run)
if run {
s.log.Info("Queue started via web UI")
} else {
s.log.Info("Queue held via web UI")
}
w.WriteHeader(http.StatusNoContent)
}
}
// actionHandler wraps a no-arg control action (pause/resume). POST only.
func (s *Server) actionHandler(fn func() error, logMsg string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w)
return
}
if err := fn(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.log.Info(logMsg + " via web UI")
w.WriteHeader(http.StatusNoContent)
}
}
// handleRetry moves a named file from failed/ back to input/. POST ?file=NAME.
func (s *Server) handleRetry(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w)
return
}
name := r.URL.Query().Get("file")
if name == "" {
http.Error(w, "missing file", http.StatusBadRequest)
return
}
if err := s.controls.RetryFailed(name); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.log.Info("Retry requested via web UI: " + name)
w.WriteHeader(http.StatusNoContent)
}
func methodNotAllowed(w http.ResponseWriter) {
w.Header().Set("Allow", "POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(settingsHTML)
}
// handleAPISettings serves the current settings (GET) and saves new ones (PUT).
// Validation lives in settings.Store.Set; a bad payload returns 400.
func (s *Server) handleAPISettings(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(s.store.Get())
case http.MethodPut:
var in settings.Settings
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
if err := s.store.Set(in); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.log.SetRetention(in.LogRetentionDays)
s.log.Info("Settings updated via web UI")
w.WriteHeader(http.StatusNoContent)
default:
w.Header().Set("Allow", "GET, PUT")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(indexHTML)
}
type statusResponse struct {
Running bool `json:"running"`
Current status.Snapshot `json:"current"`
Queue []string `json:"queue"`
Failed []string `json:"failed"`
QueueETASec int `json:"queue_eta_sec"`
QueueETAPartial bool `json:"queue_eta_partial"`
Recent []logger.LogEntry `json:"recent"`
}
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
recent, err := s.log.RecentLogs(50)
if err != nil {
http.Error(w, "reading logs", http.StatusInternalServerError)
return
}
snap := s.tracker.Snapshot()
files := watcher.InputFiles(s.inputDir)
// Sum durations of pending files (excluding the current job, whose remaining
// time is already in snap.ETASec). Unprobed files mark the estimate partial.
var sumQueued float64
partial := false
for _, f := range files {
if f == snap.File {
continue
}
info, statErr := os.Stat(f)
if statErr != nil {
partial = true
continue
}
if sec, ok := s.durCache.Get(f, info.ModTime()); ok {
sumQueued += sec
} else {
partial = true
}
}
s.durCache.Retain(files)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(statusResponse{
Running: s.controls.Running(),
Current: snap,
Queue: baseNames(files),
Failed: baseNames(watcher.InputFiles(s.failedDir)),
QueueETASec: queueETASeconds(snap.ETASec, sumQueued, snap.Speed),
QueueETAPartial: partial,
Recent: recent,
})
}
func baseNames(paths []string) []string {
out := []string{}
for _, p := range paths {
out = append(out, filepath.Base(p))
}
return out
}
+62
View File
@@ -0,0 +1,62 @@
package server
import (
"testing"
"time"
)
func TestQueueETASeconds(t *testing.T) {
// No speed → can't extrapolate.
if got := queueETASeconds(100, 3600, 0); got != 0 {
t.Errorf("speed 0: got %d, want 0", got)
}
// current remaining 600s + 1800s of queued source at 0.5x (=3600s) = 4200s.
if got := queueETASeconds(600, 1800, 0.5); got != 4200 {
t.Errorf("got %d, want 4200", got)
}
// At 2x, 1800s of source encodes in 900s; + 600 remaining = 1500.
if got := queueETASeconds(600, 1800, 2); got != 1500 {
t.Errorf("got %d, want 1500", got)
}
// Empty queue → just the current job's remaining.
if got := queueETASeconds(600, 0, 1); got != 600 {
t.Errorf("got %d, want 600", got)
}
}
// waitCached polls Get (the probe resolves on a background goroutine).
func waitCached(c *durationCache, p string, mt time.Time) (float64, bool) {
for i := 0; i < 200; i++ {
if sec, ok := c.Get(p, mt); ok {
return sec, true
}
time.Sleep(2 * time.Millisecond)
}
return 0, false
}
func TestDurationCache(t *testing.T) {
c := newDurationCache(func(p string) (float64, error) { return 42, nil })
mt := time.Unix(1000, 0)
if _, ok := c.Get("/a.mkv", mt); ok {
t.Fatal("first Get should miss")
}
if sec, ok := waitCached(c, "/a.mkv", mt); !ok || sec != 42 {
t.Fatalf("after probe: got %v ok=%v, want 42 true", sec, ok)
}
// A changed mtime invalidates the entry.
if _, ok := c.Get("/a.mkv", time.Unix(2000, 0)); ok {
t.Error("changed mtime should miss")
}
if _, ok := waitCached(c, "/a.mkv", time.Unix(2000, 0)); !ok {
t.Fatal("re-probe under new mtime should cache")
}
// Retain drops entries for paths not in the keep set.
c.Retain([]string{"/b.mkv"})
if _, ok := c.Get("/a.mkv", time.Unix(2000, 0)); ok {
t.Error("Retain should have dropped /a.mkv")
}
}
+159
View File
@@ -0,0 +1,159 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>av1dae — settings</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;
}
* { box-sizing:border-box; }
body {
margin:0 auto; max-width:720px; background:var(--bg); color:var(--text);
font-family:var(--sans); font-size:16px; line-height:1.5;
-webkit-font-smoothing:antialiased; padding:clamp(1.25rem,4vw,2.5rem);
}
a { color:var(--cyan); text-decoration:none; }
a:hover { text-decoration:underline; }
header { display:flex; align-items:baseline; gap:1rem; margin-bottom:1.75rem; }
.wordmark { font-family:var(--mono); font-weight:600; font-size:1.4rem; letter-spacing:-.02em; }
.wordmark .prompt { color:var(--dim); }
.wordmark .vibe { color:var(--amber); }
.crumb { color:var(--muted); font-family:var(--mono); font-size:.85rem; }
.back { margin-left:auto; font-family:var(--mono); font-size:.8rem; }
.card { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:1.3rem 1.5rem; margin-bottom:1.25rem; }
.eyebrow { font-family:var(--mono); font-size:.7rem; letter-spacing:.16em; text-transform:uppercase; color:var(--amber); margin:0 0 1rem; }
.hint { color:var(--muted); font-size:.85rem; margin:.1rem 0 0; }
.row { display:flex; align-items:center; gap:1rem; padding:.5rem 0; flex-wrap:wrap; }
.row label { flex:1; min-width:12rem; }
.row label .sub { display:block; color:var(--dim); font-size:.78rem; }
input[type=number], input[type=text] {
background:var(--panel-2); border:1px solid var(--line); color:var(--text);
border-radius:6px; padding:.4rem .55rem; font-family:var(--mono); font-size:.9rem; width:6rem;
}
input[type=text] { width:100%; max-width:22rem; }
input:focus { outline:2px solid var(--amber); outline-offset:1px; }
/* profiles grid */
table.prof { width:100%; border-collapse:collapse; }
table.prof th, table.prof td { text-align:left; padding:.4rem .5rem; }
table.prof th { font-family:var(--mono); font-size:.66rem; letter-spacing:.08em; text-transform:uppercase; color:var(--dim); font-weight:500; }
table.prof td.name { font-family:var(--mono); color:var(--amber-soft); }
.toggle { display:flex; align-items:center; gap:.6rem; }
.toggle input { width:1.1rem; height:1.1rem; accent-color:var(--amber); }
.actions { display:flex; align-items:center; gap:1rem; margin-top:.5rem; }
button {
font-family:var(--mono); font-size:.85rem; background:var(--amber); color:#1a1200;
border:0; border-radius:7px; padding:.55rem 1.1rem; cursor:pointer; font-weight:600;
}
button:hover { background:var(--amber-soft); }
button:disabled { opacity:.5; cursor:default; }
#msg { font-family:var(--mono); font-size:.82rem; }
#msg.ok { color:var(--green); }
#msg.err { color:var(--red); }
</style>
</head>
<body>
<header>
<span class="wordmark"><span class="prompt">$ </span>av<span class="vibe">1</span>dae</span>
<span class="crumb">/ settings</span>
<a class="back" href="/">← dashboard</a>
</header>
<form id="form">
<section class="card">
<p class="eyebrow">Encoding profiles</p>
<table class="prof">
<thead><tr><th>Source</th><th>CRF (063)</th><th>Preset (013)</th></tr></thead>
<tbody>
<tr><td class="name">dvd</td><td><input type="number" id="dvd_crf" min="0" max="63"></td><td><input type="number" id="dvd_preset" min="0" max="13"></td></tr>
<tr><td class="name">bluray</td><td><input type="number" id="bluray_crf" min="0" max="63"></td><td><input type="number" id="bluray_preset" min="0" max="13"></td></tr>
<tr><td class="name">webdl</td><td><input type="number" id="webdl_crf" min="0" max="63"></td><td><input type="number" id="webdl_preset" min="0" max="13"></td></tr>
<tr><td class="name">tvrip</td><td><input type="number" id="tvrip_crf" min="0" max="63"></td><td><input type="number" id="tvrip_preset" min="0" max="13"></td></tr>
</tbody>
</table>
<div class="row" style="margin-top:.6rem">
<label>Thread cap (lp)<span class="sub">SVT-AV1 logical processors · 0 = use all cores</span></label>
<input type="number" id="lp" min="0">
</div>
</section>
<section class="card">
<p class="eyebrow">Metadata & housekeeping</p>
<div class="row">
<label>OMDb API key<span class="sub">Required for movie metadata; TVmaze needs none</span></label>
<input type="text" id="omdb_api_key" autocomplete="off" spellcheck="false">
</div>
<div class="row">
<label>Log retention (days)<span class="sub">Rows older than this are purged from logs.db</span></label>
<input type="number" id="log_retention_days" min="1">
</div>
<div class="row">
<label class="toggle"><input type="checkbox" id="delete_originals"> Delete originals after a successful encode<span class="sub">Off = move them to originals/</span></label>
</div>
</section>
<div class="actions">
<button type="submit" id="save">Save settings</button>
<span id="msg"></span>
</div>
</form>
<script>
const $ = id => document.getElementById(id);
const profiles = ["dvd", "bluray", "webdl", "tvrip"];
function fill(s) {
for (const p of profiles) { $(p + "_crf").value = s[p].crf; $(p + "_preset").value = s[p].preset; }
$("lp").value = s.lp;
$("omdb_api_key").value = s.omdb_api_key || "";
$("log_retention_days").value = s.log_retention_days;
$("delete_originals").checked = !!s.delete_originals;
}
function collect() {
const s = { lp: +$("lp").value, omdb_api_key: $("omdb_api_key").value,
log_retention_days: +$("log_retention_days").value, delete_originals: $("delete_originals").checked };
for (const p of profiles) s[p] = { crf: +$(p + "_crf").value, preset: +$(p + "_preset").value };
return s;
}
function msg(text, cls) { const m = $("msg"); m.textContent = text; m.className = cls || ""; }
async function load() {
try {
const r = await fetch("/api/settings", { cache: "no-store" });
if (!r.ok) throw new Error(r.status);
fill(await r.json());
} catch (e) { msg("Failed to load settings", "err"); }
}
$("form").addEventListener("submit", async e => {
e.preventDefault();
$("save").disabled = true; msg("Saving…");
try {
const r = await fetch("/api/settings", {
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(collect())
});
if (r.ok) msg("Saved — applies to the next encode.", "ok");
else msg("Rejected: " + (await r.text()).trim(), "err");
} catch (e) { msg("Network error", "err"); }
finally { $("save").disabled = false; }
});
load();
</script>
</body>
</html>
+130
View File
@@ -0,0 +1,130 @@
// Package settings holds the live-editable configuration: values that can be
// changed from the web UI and persisted, without a config-file edit + restart.
// config.yaml seeds the store on first run; afterwards the DB is the source of
// truth for these values (paths.* and http_addr stay config-only).
package settings
import (
"database/sql"
"encoding/json"
"fmt"
"sync"
"av1dae/pkg/types"
)
// Profile is a per-source-type SVT-AV1 quality pair.
type Profile struct {
CRF int `json:"crf"`
Preset int `json:"preset"`
}
// Settings is the full set of live-editable values.
type Settings struct {
DVD Profile `json:"dvd"`
Bluray Profile `json:"bluray"`
WebDL Profile `json:"webdl"`
TVRip Profile `json:"tvrip"`
LP int `json:"lp"`
OMDBAPIKey string `json:"omdb_api_key"`
LogRetentionDays int `json:"log_retention_days"`
DeleteOriginals bool `json:"delete_originals"`
}
// ProfileFor returns the encoding profile for a media type.
func (s Settings) ProfileFor(mt types.MediaType) Profile {
switch mt {
case types.MediaTypeBluRay:
return s.Bluray
case types.MediaTypeWebDL:
return s.WebDL
case types.MediaTypeTVRip:
return s.TVRip
default:
return s.DVD
}
}
// Validate guards the trust boundary: a bad value from the PUT handler could
// silently break every subsequent encode. Ranges follow SVT-AV1's limits.
func (s Settings) Validate() error {
for name, p := range map[string]Profile{"dvd": s.DVD, "bluray": s.Bluray, "webdl": s.WebDL, "tvrip": s.TVRip} {
if p.CRF < 0 || p.CRF > 63 {
return fmt.Errorf("%s crf %d out of range 0-63", name, p.CRF)
}
if p.Preset < 0 || p.Preset > 13 {
return fmt.Errorf("%s preset %d out of range 0-13", name, p.Preset)
}
}
if s.LP < 0 {
return fmt.Errorf("lp %d must be >= 0", s.LP)
}
if s.LogRetentionDays < 1 {
return fmt.Errorf("log_retention_days %d must be >= 1", s.LogRetentionDays)
}
return nil
}
// Store is the persisted, concurrency-safe settings holder. The encode loop
// reads via Get; the HTTP handler writes via Set.
type Store struct {
mu sync.RWMutex
db *sql.DB
cur Settings
}
// New creates the settings table if needed, loads the persisted row, or seeds
// it from `seed` (the config-derived values) on first run.
func New(db *sql.DB, seed Settings) (*Store, error) {
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS settings (id INTEGER PRIMARY KEY CHECK (id = 1), data TEXT NOT NULL)`); err != nil {
return nil, fmt.Errorf("settings schema: %w", err)
}
s := &Store{db: db, cur: seed}
var data string
switch err := db.QueryRow(`SELECT data FROM settings WHERE id = 1`).Scan(&data); err {
case sql.ErrNoRows:
if err := s.persist(seed); err != nil { // first run — seed from config
return nil, err
}
case nil:
var loaded Settings
if err := json.Unmarshal([]byte(data), &loaded); err != nil {
return nil, fmt.Errorf("decoding settings: %w", err)
}
s.cur = loaded
default:
return nil, fmt.Errorf("loading settings: %w", err)
}
return s, nil
}
// Get returns a copy of the current settings.
func (s *Store) Get() Settings {
s.mu.RLock()
defer s.mu.RUnlock()
return s.cur
}
// Set validates, persists, and swaps in the new settings.
func (s *Store) Set(n Settings) error {
if err := n.Validate(); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
if err := s.persist(n); err != nil {
return err
}
s.cur = n
return nil
}
func (s *Store) persist(n Settings) error {
b, err := json.Marshal(n)
if err != nil {
return err
}
_, err = s.db.Exec(`INSERT INTO settings (id, data) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data`, string(b))
return err
}
+46
View File
@@ -0,0 +1,46 @@
package settings
import (
"testing"
"av1dae/pkg/types"
)
func valid() Settings {
return Settings{
DVD: Profile{30, 2}, Bluray: Profile{29, 3}, WebDL: Profile{30, 3}, TVRip: Profile{32, 2},
LP: 0, LogRetentionDays: 7,
}
}
func TestValidate(t *testing.T) {
if err := valid().Validate(); err != nil {
t.Fatalf("valid settings rejected: %v", err)
}
bad := func(mut func(*Settings)) Settings { s := valid(); mut(&s); return s }
cases := map[string]Settings{
"crf too high": bad(func(s *Settings) { s.DVD.CRF = 64 }),
"crf negative": bad(func(s *Settings) { s.Bluray.CRF = -1 }),
"preset too high": bad(func(s *Settings) { s.WebDL.Preset = 14 }),
"lp negative": bad(func(s *Settings) { s.LP = -1 }),
"retention zero": bad(func(s *Settings) { s.LogRetentionDays = 0 }),
}
for name, s := range cases {
if err := s.Validate(); err == nil {
t.Errorf("%s: expected validation error, got nil", name)
}
}
}
func TestProfileFor(t *testing.T) {
s := valid()
if s.ProfileFor(types.MediaTypeBluRay) != s.Bluray {
t.Error("bluray profile mismatch")
}
if s.ProfileFor(types.MediaTypeWebDL) != s.WebDL {
t.Error("webdl profile mismatch")
}
if s.ProfileFor(types.MediaType("anything-else")) != s.DVD {
t.Error("unknown media type should fall back to DVD")
}
}
+280
View File
@@ -0,0 +1,280 @@
// Package status tracks the live state of the single in-flight encode job.
// The watcher processes one file at a time, so one mutex-guarded value is
// enough — no per-job table, no concurrency design.
package status
import (
"bufio"
"io"
"strconv"
"strings"
"sync"
"time"
)
// Phase labels for the current job.
const (
PhaseIdle = "idle"
PhaseProbing = "probing"
PhaseAudio = "audio"
PhaseEncoding = "encoding"
)
// JobMeta is the fetched metadata for the active job, shaped for display.
type JobMeta struct {
IsSeries bool `json:"is_series"`
Title string `json:"title"` // movie title or episode title
Collection string `json:"collection,omitempty"` // show name (series)
Season string `json:"season,omitempty"`
Episode string `json:"episode,omitempty"`
DateReleased string `json:"date_released,omitempty"` // release date / airdate
MediaType string `json:"media_type,omitempty"`
}
// Stream is one source audio or subtitle stream, for display.
type Stream struct {
Kind string `json:"kind"` // "audio" | "subtitle"
Language string `json:"language,omitempty"`
Codec string `json:"codec,omitempty"`
Channels int `json:"channels,omitempty"` // audio only
Title string `json:"title,omitempty"`
}
// Tracker holds live progress for the active job. Safe for concurrent use:
// the encode goroutine writes, HTTP/log readers call Snapshot.
type Tracker struct {
mu sync.RWMutex
file string
phase string
meta *JobMeta
streams []Stream
totalSec float64 // source duration; 0 until known
outTime float64 // encoded position in seconds
fps float64
speed float64
startedAt time.Time
paused bool
pausedAt time.Time // when the current pause began
pausedTotal time.Duration // accumulated paused time this job
}
// Snapshot is an immutable view of the tracker for readers.
type Snapshot struct {
File string `json:"file"`
Phase string `json:"phase"`
Meta *JobMeta `json:"meta"`
Streams []Stream `json:"streams"`
Percent float64 `json:"percent"`
FPS float64 `json:"fps"`
Speed float64 `json:"speed"`
Paused bool `json:"paused"`
ElapsedSec int `json:"elapsed_sec"`
ETASec int `json:"eta_sec"`
StartedAt time.Time `json:"started_at"`
}
func New() *Tracker {
return &Tracker{phase: PhaseIdle}
}
// Begin marks the start of a new job, resetting all progress fields.
func (t *Tracker) Begin(file string) {
t.mu.Lock()
defer t.mu.Unlock()
t.file = file
t.phase = PhaseProbing
t.meta = nil
t.streams = nil
t.totalSec, t.outTime, t.fps, t.speed = 0, 0, 0, 0
t.startedAt = time.Now()
t.paused = false
t.pausedAt = time.Time{}
t.pausedTotal = 0
}
// SetPaused records pause/resume transitions so elapsed time excludes the
// paused span. Idempotent on repeated same-state calls.
func (t *Tracker) SetPaused(p bool) {
t.mu.Lock()
defer t.mu.Unlock()
if p == t.paused {
return
}
if p {
t.pausedAt = time.Now()
} else if !t.pausedAt.IsZero() {
t.pausedTotal += time.Since(t.pausedAt)
t.pausedAt = time.Time{}
}
t.paused = p
}
func (t *Tracker) SetPhase(p string) {
t.mu.Lock()
defer t.mu.Unlock()
t.phase = p
}
// SetMeta records the fetched metadata for the active job.
func (t *Tracker) SetMeta(m JobMeta) {
t.mu.Lock()
defer t.mu.Unlock()
t.meta = &m
}
// SetStreams records the source audio/subtitle streams for the active job.
func (t *Tracker) SetStreams(s []Stream) {
t.mu.Lock()
defer t.mu.Unlock()
t.streams = s
}
// SetTotal records the source duration in seconds (from ffprobe).
func (t *Tracker) SetTotal(seconds float64) {
t.mu.Lock()
defer t.mu.Unlock()
t.totalSec = seconds
}
// Update records one ffmpeg -progress sample.
func (t *Tracker) Update(outTimeSec, fps, speed float64) {
t.mu.Lock()
defer t.mu.Unlock()
t.outTime, t.fps, t.speed = outTimeSec, fps, speed
}
// Idle clears the tracker when no job is running.
func (t *Tracker) Idle() {
t.mu.Lock()
defer t.mu.Unlock()
t.file = ""
t.phase = PhaseIdle
t.meta = nil
t.streams = nil
t.totalSec, t.outTime, t.fps, t.speed = 0, 0, 0, 0
t.startedAt = time.Time{}
t.paused = false
t.pausedAt = time.Time{}
t.pausedTotal = 0
}
func (t *Tracker) Snapshot() Snapshot {
t.mu.RLock()
defer t.mu.RUnlock()
s := Snapshot{
File: t.file,
Phase: t.phase,
Meta: t.meta,
Streams: t.streams,
FPS: t.fps,
Speed: t.speed,
Paused: t.paused,
StartedAt: t.startedAt,
}
if !t.startedAt.IsZero() {
elapsed := time.Since(t.startedAt) - t.pausedTotal
if t.paused && !t.pausedAt.IsZero() {
elapsed -= time.Since(t.pausedAt)
}
if elapsed < 0 {
elapsed = 0
}
s.ElapsedSec = int(elapsed.Seconds())
}
if t.totalSec > 0 {
s.Percent = t.outTime / t.totalSec * 100
if s.Percent > 100 {
s.Percent = 100
}
if t.speed > 0 {
remaining := t.totalSec - t.outTime
if remaining < 0 {
remaining = 0
}
s.ETASec = int(remaining / t.speed)
}
}
return s
}
// ProgressSample is one completed ffmpeg -progress block.
type ProgressSample struct {
OutTimeSec float64
FPS float64
Speed float64
Done bool // progress=end
}
// ScanProgress reads ffmpeg `-progress` key=value output from r and calls
// onSample once per block (each block is terminated by a "progress=" line).
// Returns when r is exhausted.
func ScanProgress(r io.Reader, onSample func(ProgressSample)) error {
sc := bufio.NewScanner(r)
var cur ProgressSample
for sc.Scan() {
if parseProgressLine(sc.Text(), &cur) {
onSample(cur)
cur = ProgressSample{}
}
}
return sc.Err()
}
// parseProgressLine folds one "key=value" line into s. Returns true when the
// line closes a block (key == "progress").
//
// ponytail: ffmpeg field naming drifts between builds — out_time_us is the
// modern microsecond field; out_time_ms is historically ALSO microseconds (a
// known mislabel); out_time is the "HH:MM:SS.ffffff" string. Prefer out_time_us,
// fall back to the others only if it hasn't set a value this block. Verify
// against the ffmpeg in the Docker image if percentages look off.
func parseProgressLine(line string, s *ProgressSample) (complete bool) {
k, v, ok := strings.Cut(line, "=")
if !ok {
return false
}
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
switch k {
case "out_time_us":
if us, err := strconv.ParseFloat(v, 64); err == nil {
s.OutTimeSec = us / 1e6
}
case "out_time_ms":
if s.OutTimeSec == 0 {
if ms, err := strconv.ParseFloat(v, 64); err == nil {
s.OutTimeSec = ms / 1e6 // really microseconds, see note above
}
}
case "out_time":
if s.OutTimeSec == 0 {
s.OutTimeSec = parseTimecode(v)
}
case "fps":
if f, err := strconv.ParseFloat(v, 64); err == nil {
s.FPS = f
}
case "speed":
if f, err := strconv.ParseFloat(strings.TrimSuffix(v, "x"), 64); err == nil {
s.Speed = f // "N/A" leaves it 0
}
case "progress":
s.Done = v == "end"
return true
}
return false
}
// parseTimecode parses "HH:MM:SS.ffffff" into seconds; 0 on bad input.
func parseTimecode(tc string) float64 {
parts := strings.Split(tc, ":")
if len(parts) != 3 {
return 0
}
h, err1 := strconv.ParseFloat(parts[0], 64)
m, err2 := strconv.ParseFloat(parts[1], 64)
sec, err3 := strconv.ParseFloat(parts[2], 64)
if err1 != nil || err2 != nil || err3 != nil {
return 0
}
return h*3600 + m*60 + sec
}
+110
View File
@@ -0,0 +1,110 @@
package status
import (
"strings"
"testing"
)
func TestScanProgress(t *testing.T) {
tests := []struct {
name string
input string
wantOutSec, wantFPS, wantSpd float64
wantDone bool
}{
{
name: "out_time_us preferred over ms and string",
input: "frame=120\nfps=24.00\nout_time_us=5000000\nout_time_ms=9999999\nout_time=00:00:09.000000\nspeed=1.02x\nprogress=continue\n",
wantOutSec: 5, wantFPS: 24, wantSpd: 1.02, wantDone: false,
},
{
name: "out_time string fallback when no us field",
input: "fps=12\nout_time=00:01:30.500000\nspeed=0.09x\nprogress=continue\n",
wantOutSec: 90.5, wantFPS: 12, wantSpd: 0.09, wantDone: false,
},
{
name: "end block",
input: "out_time_us=7200000000\nspeed=2x\nprogress=end\n",
wantOutSec: 7200, wantFPS: 0, wantSpd: 2, wantDone: true,
},
{
name: "speed N/A leaves zero",
input: "out_time_us=1000000\nspeed=N/A\nprogress=continue\n",
wantOutSec: 1, wantFPS: 0, wantSpd: 0, wantDone: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var last ProgressSample
n := 0
if err := ScanProgress(strings.NewReader(tt.input), func(s ProgressSample) { last = s; n++ }); err != nil {
t.Fatalf("ScanProgress: %v", err)
}
if n != 1 {
t.Fatalf("got %d samples, want 1", n)
}
if last.OutTimeSec != tt.wantOutSec || last.FPS != tt.wantFPS || last.Speed != tt.wantSpd || last.Done != tt.wantDone {
t.Errorf("got %+v, want out=%v fps=%v spd=%v done=%v", last, tt.wantOutSec, tt.wantFPS, tt.wantSpd, tt.wantDone)
}
})
}
}
func TestScanProgressMultipleBlocks(t *testing.T) {
in := "out_time_us=1000000\nspeed=1x\nprogress=continue\nout_time_us=2000000\nspeed=1x\nprogress=end\n"
var got []ProgressSample
if err := ScanProgress(strings.NewReader(in), func(s ProgressSample) { got = append(got, s) }); err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("got %d blocks, want 2", len(got))
}
if got[0].OutTimeSec != 1 || got[1].OutTimeSec != 2 || got[0].Done || !got[1].Done {
t.Errorf("blocks = %+v", got)
}
}
func TestSnapshotPercentAndETA(t *testing.T) {
tr := New()
tr.Begin("episode.mkv")
tr.SetTotal(100)
tr.Update(25, 10, 0.5) // 25% done, 75s left at 0.5x -> 150s ETA
s := tr.Snapshot()
if s.Percent != 25 {
t.Errorf("Percent = %v, want 25", s.Percent)
}
if s.ETASec != 150 {
t.Errorf("ETASec = %v, want 150", s.ETASec)
}
if s.File != "episode.mkv" || s.Phase != PhaseProbing {
t.Errorf("File/Phase = %q/%q", s.File, s.Phase)
}
}
func TestSetPausedFlag(t *testing.T) {
tr := New()
tr.Begin("x.mkv")
if tr.Snapshot().Paused {
t.Fatal("should not start paused")
}
tr.SetPaused(true)
if !tr.Snapshot().Paused {
t.Error("expected paused after SetPaused(true)")
}
tr.SetPaused(true) // idempotent — must not double-count
tr.SetPaused(false)
if tr.Snapshot().Paused {
t.Error("expected not paused after SetPaused(false)")
}
tr.Idle()
if tr.Snapshot().Paused {
t.Error("Idle should clear paused")
}
}
func TestSnapshotIdleNoDivByZero(t *testing.T) {
s := New().Snapshot() // no Begin, totalSec 0
if s.Percent != 0 || s.ETASec != 0 || s.Phase != PhaseIdle {
t.Errorf("idle snapshot = %+v", s)
}
}
+42 -2
View File
@@ -5,9 +5,11 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"sync"
"time" "time"
"videnc-vibe/pkg/types" "av1dae/pkg/types"
) )
// failureBackoff is how long we skip a file after processFn returned an error // failureBackoff is how long we skip a file after processFn returned an error
@@ -28,6 +30,19 @@ var inputExtensions = []string{
"webm", "wmv", "flv", "webm", "wmv", "flv",
} }
// InputFiles returns the source files currently in dir that the watcher would
// consider, sorted. The status server uses this to report the pending queue, so
// it stays in sync with inputExtensions.
func InputFiles(dir string) []string {
var files []string
for _, ext := range inputExtensions {
matches, _ := filepath.Glob(filepath.Join(dir, "*."+ext))
files = append(files, matches...)
}
sort.Strings(files)
return files
}
// fileStat is the (mtime, size) pair used to decide whether a file has settled // fileStat is the (mtime, size) pair used to decide whether a file has settled
// between two consecutive ticks. // between two consecutive ticks.
type fileStat struct { type fileStat struct {
@@ -45,6 +60,10 @@ type failureRecord struct {
type Watcher struct { type Watcher struct {
inputDir string inputDir string
interval time.Duration interval time.Duration
// runMu guards running: the start/hold gate. While held, settled files are
// tracked (so the queue is reported) but not handed to processFn.
runMu sync.RWMutex
running bool
// seen tracks the last-observed (mtime, size) for every file currently in // seen tracks the last-observed (mtime, size) for every file currently in
// the input dir. A file is only handed to processFn once two consecutive // the input dir. A file is only handed to processFn once two consecutive
// ticks agree on both fields (partial-write protection). // ticks agree on both fields (partial-write protection).
@@ -54,7 +73,7 @@ type Watcher struct {
failed map[string]failureRecord failed map[string]failureRecord
} }
func New(inputDir string, intervalSeconds int) *Watcher { func New(inputDir string, intervalSeconds int, autostart bool) *Watcher {
interval := time.Duration(intervalSeconds) * time.Second interval := time.Duration(intervalSeconds) * time.Second
if interval < 10*time.Second { if interval < 10*time.Second {
interval = 10 * time.Second interval = 10 * time.Second
@@ -65,11 +84,26 @@ func New(inputDir string, intervalSeconds int) *Watcher {
return &Watcher{ return &Watcher{
inputDir: inputDir, inputDir: inputDir,
interval: interval, interval: interval,
running: autostart,
seen: make(map[string]fileStat), seen: make(map[string]fileStat),
failed: make(map[string]failureRecord), failed: make(map[string]failureRecord),
} }
} }
// SetRunning toggles the start/hold gate.
func (w *Watcher) SetRunning(v bool) {
w.runMu.Lock()
w.running = v
w.runMu.Unlock()
}
// Running reports whether the queue is being processed.
func (w *Watcher) Running() bool {
w.runMu.RLock()
defer w.runMu.RUnlock()
return w.running
}
func (w *Watcher) Start(ctx context.Context, processFn func(context.Context, string) error) { func (w *Watcher) Start(ctx context.Context, processFn func(context.Context, string) error) {
ticker := time.NewTicker(w.interval) ticker := time.NewTicker(w.interval)
defer ticker.Stop() defer ticker.Stop()
@@ -132,6 +166,12 @@ func (w *Watcher) scanAndProcess(ctx context.Context, processFn func(context.Con
continue continue
} }
if !w.Running() {
// Held: the file stays in the queue (already in nextSeen) and is
// picked up once the user starts processing.
continue
}
if err := processFn(ctx, file); err != nil { if err := processFn(ctx, file); err != nil {
fmt.Printf("Error processing %s: %v\n", file, err) fmt.Printf("Error processing %s: %v\n", file, err)
nextFailed[file] = failureRecord{ nextFailed[file] = failureRecord{
+8 -12
View File
@@ -1,9 +1,5 @@
package types package types
import (
"time"
)
type MediaType string type MediaType string
const ( const (
@@ -20,6 +16,7 @@ type Job struct {
Metadata *Metadata Metadata *Metadata
CRF int CRF int
Preset int Preset int
LP int // SVT-AV1 logical processors; 0 = auto
DeleteOrigin bool DeleteOrigin bool
} }
@@ -37,6 +34,10 @@ type Metadata struct {
type Config struct { type Config struct {
OMDBAPIKey string `yaml:"omdb_api_key"` OMDBAPIKey string `yaml:"omdb_api_key"`
LogRetentionDays int `yaml:"log_retention_days"`
// HTTPAddr is the status server listen address. Absent (nil) defaults to
// ":8080"; an explicit empty string disables the server.
HTTPAddr *string `yaml:"http_addr"`
Encoding EncodingConfig Encoding EncodingConfig
Paths PathsConfig Paths PathsConfig
} }
@@ -46,6 +47,9 @@ type EncodingConfig struct {
Bluray EncodingParams `yaml:"bluray"` Bluray EncodingParams `yaml:"bluray"`
WebDL EncodingParams `yaml:"webdl"` WebDL EncodingParams `yaml:"webdl"`
TVRip EncodingParams `yaml:"tvrip"` TVRip EncodingParams `yaml:"tvrip"`
// LP caps SVT-AV1's logical-processor (thread) count so a long encode can't
// pin every core. 0 = let SVT-AV1 auto-detect (default). Applies to all profiles.
LP int `yaml:"lp"`
} }
type EncodingParams struct { type EncodingParams struct {
@@ -60,11 +64,3 @@ type PathsConfig struct {
Failed string `yaml:"failed"` Failed string `yaml:"failed"`
Work string `yaml:"work"` Work string `yaml:"work"`
} }
type LogEntry struct {
Timestamp time.Time `json:"timestamp"`
Level string `json:"level"`
Message string `json:"message"`
File string `json:"file,omitempty"`
Error string `json:"error,omitempty"`
}