Compare commits
4
Commits
bef58f480b
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31e22bd4a7 | ||
|
|
dfbd74b0d2 | ||
|
|
04d6c85712 | ||
|
|
8fc00c96b9 |
+8
-8
@@ -505,19 +505,19 @@ old.broadcast.tvrip.tt0066026.mkv</code></pre></div>
|
|||||||
<section id="logs">
|
<section id="logs">
|
||||||
<p class="eyebrow">08 — Observability</p>
|
<p class="eyebrow">08 — Observability</p>
|
||||||
<h2>Logs</h2>
|
<h2>Logs</h2>
|
||||||
<p>Three log files are written to the <strong>current working directory</strong> (not the config paths):</p>
|
<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">
|
<div class="tablewrap">
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>File</th><th>Contents</th></tr></thead>
|
<thead><tr><th>Level</th><th>Where</th><th>Contents</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td><code>info_YYYY-MM-DD.log</code></td><td>INFO messages, dated</td></tr>
|
<tr><td><code>info</code></td><td>db + stdout</td><td>Processing / metadata hits / completions</td></tr>
|
||||||
<tr><td><code>error_YYYY-MM-DD.log</code></td><td>ERROR messages, dated</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>structured.json</code></td><td>One JSON object per line — every entry, with <code>timestamp</code>, <code>level</code>, <code>message</code>, optional <code>error</code> & <code>file</code></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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<p>Run the program from the directory where you want the logs to land. A number of <code>DEBUG</code> lines also print to stdout/stderr (ffprobe output, calculated zscale width, the full ffmpeg command) — intentional, but not written to the log files.</p>
|
<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 warn"><span class="tag">Known limitation</span><p>The date in <code>info_*.log</code> / <code>error_*.log</code> filenames is computed at daemon start and does not roll over at midnight. Left running across days, all writes continue into the start-day's file — restart to rotate. <code>structured.json</code> does not rotate at all.</p></div>
|
<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>
|
||||||
|
|
||||||
<section id="failures">
|
<section id="failures">
|
||||||
@@ -527,7 +527,7 @@ old.broadcast.tvrip.tt0066026.mkv</code></pre></div>
|
|||||||
<ul>
|
<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 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 per-job work directory (partial wav/opus/output.mkv) is deleted unconditionally.</li>
|
||||||
<li>The error is logged to <code>error_*.log</code> and <code>structured.json</code> with the source path.</li>
|
<li>The error is logged to <code>logs.db</code> (level <code>error</code>) with the source path, and printed to stderr.</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>The watcher continues with the next file; one bad rip won't stop the daemon.</p>
|
<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">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>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+68
-18
@@ -21,6 +21,7 @@ import (
|
|||||||
"av1dae/internal/metadata"
|
"av1dae/internal/metadata"
|
||||||
"av1dae/internal/mover"
|
"av1dae/internal/mover"
|
||||||
"av1dae/internal/server"
|
"av1dae/internal/server"
|
||||||
|
"av1dae/internal/settings"
|
||||||
"av1dae/internal/status"
|
"av1dae/internal/status"
|
||||||
"av1dae/internal/watcher"
|
"av1dae/internal/watcher"
|
||||||
"av1dae/pkg/types"
|
"av1dae/pkg/types"
|
||||||
@@ -65,13 +66,59 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
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()
|
||||||
|
|
||||||
|
// 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 != "" {
|
if addr := *cfg.HTTPAddr; addr != "" {
|
||||||
srv := &http.Server{Addr: addr, Handler: server.New(tracker, log, cfg.Paths.Input).Handler()}
|
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() {
|
go func() {
|
||||||
log.Info(fmt.Sprintf("Status server listening on %s", addr))
|
log.Info(fmt.Sprintf("Status server listening on %s", addr))
|
||||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
@@ -86,17 +133,21 @@ func main() {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
w := watcher.New(cfg.Paths.Input, 15)
|
|
||||||
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, tracker)
|
return processFile(ctx, inputPath, cfg, enc, metaClient, log, tracker, store)
|
||||||
})
|
})
|
||||||
|
|
||||||
log.Info("av1dae started")
|
log.Info("av1dae started")
|
||||||
}
|
}
|
||||||
|
|
||||||
func processFile(ctx context.Context, inputPath string, cfg *types.Config, enc *encoder.Encoder, metaClient *metadata.Client, log *logger.Logger, tracker *status.Tracker) 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
|
// Mark this file as the active job (phase: probing) and clear the tracker
|
||||||
// back to idle on every exit path — success, failure, or cancellation.
|
// back to idle on every exit path — success, failure, or cancellation.
|
||||||
tracker.Begin(inputPath)
|
tracker.Begin(inputPath)
|
||||||
@@ -136,17 +187,7 @@ func processFile(ctx context.Context, inputPath string, cfg *types.Config, enc *
|
|||||||
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
|
||||||
|
|
||||||
@@ -192,8 +233,8 @@ func processFile(ctx context.Context, inputPath string, cfg *types.Config, enc *
|
|||||||
MediaType: mediaType,
|
MediaType: mediaType,
|
||||||
CRF: crf,
|
CRF: crf,
|
||||||
Preset: preset,
|
Preset: preset,
|
||||||
LP: cfg.Encoding.LP,
|
LP: cur.LP,
|
||||||
DeleteOrigin: deleteOrigin,
|
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 {
|
||||||
@@ -224,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 {
|
||||||
@@ -253,6 +294,15 @@ 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
|
// toStatusStreams maps probed source streams to the display shape, keeping only
|
||||||
// audio and subtitle streams (the video stream isn't shown).
|
// audio and subtitle streams (the video stream isn't shown).
|
||||||
func toStatusStreams(streams []encoder.StreamMetadata) []status.Stream {
|
func toStatusStreams(streams []encoder.StreamMetadata) []status.Stream {
|
||||||
|
|||||||
+4
-1
@@ -12,7 +12,10 @@ services:
|
|||||||
cpus: "8.0"
|
cpus: "8.0"
|
||||||
memory: 4g
|
memory: 4g
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080" # status server: GET http://host:8080/status (needs http_addr: ":8080" in config)
|
- "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:
|
volumes:
|
||||||
- ./data/config.yaml:/config/config.yaml:ro # your config — paths inside must point at /data/*
|
- ./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
|
- ./data/media:/data # holds input/ output/ originals/ failed/ work/ + logs
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ import (
|
|||||||
"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"
|
"time"
|
||||||
|
|
||||||
"av1dae/internal/logger"
|
"av1dae/internal/logger"
|
||||||
@@ -26,6 +29,59 @@ type Encoder struct {
|
|||||||
opusencPath string
|
opusencPath string
|
||||||
log *logger.Logger
|
log *logger.Logger
|
||||||
tracker *status.Tracker
|
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 {
|
||||||
@@ -94,6 +150,9 @@ func (e *Encoder) runCmdProgress(ctx context.Context, label, file, name string,
|
|||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
return nil, err
|
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.
|
// Reads stdout to EOF (when ffmpeg exits), so Wait below is safe afterwards.
|
||||||
var lastLog time.Time
|
var lastLog time.Time
|
||||||
|
|||||||
@@ -113,6 +113,18 @@ func (l *Logger) Debug(message, file, extra string) {
|
|||||||
l.write(LevelDebug, message, file, extra)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// LogEntry is one row returned by RecentLogs, shaped for the status feed.
|
// LogEntry is one row returned by RecentLogs, shaped for the status feed.
|
||||||
type LogEntry struct {
|
type LogEntry struct {
|
||||||
TS string `json:"ts"`
|
TS string `json:"ts"`
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ func NewClient(apiKey string, log *logger.Logger) *Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
// redactURL strips secret query parameters (e.g. apikey) before logging.
|
||||||
func redactURL(rawURL string) string {
|
func redactURL(rawURL string) string {
|
||||||
u, err := url.Parse(rawURL)
|
u, err := url.Parse(rawURL)
|
||||||
|
|||||||
@@ -27,6 +27,8 @@
|
|||||||
.wordmark { font-family:var(--mono); font-weight:600; font-size:1.4rem; letter-spacing:-.02em; }
|
.wordmark { font-family:var(--mono); font-weight:600; font-size:1.4rem; letter-spacing:-.02em; }
|
||||||
.wordmark .prompt { color:var(--dim); }
|
.wordmark .prompt { color:var(--dim); }
|
||||||
.wordmark .vibe { color:var(--amber); }
|
.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; }
|
.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 { 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.ok { background:var(--green); box-shadow:0 0 0 0 rgba(92,201,139,.6); animation:pulse 2s infinite; }
|
||||||
@@ -95,6 +97,7 @@
|
|||||||
.qsum { display:flex; align-items:baseline; gap:.5rem; flex-wrap:wrap; margin-bottom:.8rem; }
|
.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 .qn { font-family:var(--mono); font-size:1.4rem; color:var(--amber); line-height:1; }
|
||||||
.qsum .ql { color:var(--muted); font-size:.85rem; }
|
.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 { 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 { 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:last-child { border-bottom:0; }
|
||||||
@@ -112,19 +115,43 @@
|
|||||||
.day:first-child { margin-top:0; }
|
.day:first-child { margin-top:0; }
|
||||||
|
|
||||||
@media (prefers-reduced-motion:reduce){ .dot.ok{animation:none;} .bar.indet>i{animation:none; width:100%; opacity:.4;} }
|
@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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<span class="wordmark"><span class="prompt">$ </span>av<span class="vibe">1</span>dae</span>
|
<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>
|
<span class="live"><span class="dot" id="dot"></span><span id="livetext">connecting</span></span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div class="controls" id="controls"></div>
|
||||||
|
|
||||||
<section class="card" id="job">
|
<section class="card" id="job">
|
||||||
<div id="job-body"></div>
|
<div id="job-body"></div>
|
||||||
</section>
|
</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">
|
<div class="grid">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<p class="eyebrow">Queue</p>
|
<p class="eyebrow">Queue</p>
|
||||||
@@ -299,7 +326,8 @@
|
|||||||
for (const s of arr) { while (!s.startsWith(p)) p = p.slice(0, -1); if (!p) break; }
|
for (const s of arr) { while (!s.startsWith(p)) p = p.slice(0, -1); if (!p) break; }
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
function renderQueue(queue, current) {
|
function renderQueue(d) {
|
||||||
|
const queue = d.queue, current = d.current;
|
||||||
const cur = base(current && current.file);
|
const cur = base(current && current.file);
|
||||||
const pending = (queue || []).filter(f => f !== cur);
|
const pending = (queue || []).filter(f => f !== cur);
|
||||||
const el = $("queue");
|
const el = $("queue");
|
||||||
@@ -308,7 +336,10 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const pre = pending.length > 1 ? commonPrefix(pending) : "";
|
const pre = pending.length > 1 ? commonPrefix(pending) : "";
|
||||||
const head = `<div class="qsum"><span class="qn">${pending.length}</span><span class="ql">${pending.length === 1 ? "file" : "files"} waiting</span></div>`;
|
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 rows = pending.slice(0, 3).map(f => {
|
||||||
const tail = pre ? f.slice(pre.length) : 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>`;
|
return `<li>${pre ? `<span class="pre">${esc(pre)}</span>` : ""}<span class="key">${esc(tail)}</span></li>`;
|
||||||
@@ -353,17 +384,60 @@
|
|||||||
txt.textContent = state === "ok" ? "live" : state === "idle" ? "idle" : state === "err" ? "offline" : "connecting";
|
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() {
|
async function poll() {
|
||||||
try {
|
try {
|
||||||
const r = await fetch("status", { cache: "no-store" });
|
const r = await fetch("status", { cache: "no-store" });
|
||||||
if (!r.ok) throw new Error(r.status);
|
if (!r.ok) throw new Error(r.status);
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
pushSpeed(d.current);
|
pushSpeed(d.current);
|
||||||
|
renderControls(d);
|
||||||
renderJob(d.current);
|
renderJob(d.current);
|
||||||
renderQueue(d.queue, d.current);
|
renderFailed(d.failed);
|
||||||
|
renderQueue(d);
|
||||||
renderEvents(d.recent);
|
renderEvents(d.recent);
|
||||||
const active = d.current && d.current.file && d.current.phase !== "idle";
|
const active = d.current && d.current.file && d.current.phase !== "idle";
|
||||||
setLive(active ? "ok" : "idle");
|
setLive(d.current && d.current.paused ? "idle" : active ? "ok" : "idle");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setLive("err");
|
setLive("err");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+238
-9
@@ -6,9 +6,13 @@ import (
|
|||||||
_ "embed"
|
_ "embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"av1dae/internal/logger"
|
"av1dae/internal/logger"
|
||||||
|
"av1dae/internal/settings"
|
||||||
"av1dae/internal/status"
|
"av1dae/internal/status"
|
||||||
"av1dae/internal/watcher"
|
"av1dae/internal/watcher"
|
||||||
)
|
)
|
||||||
@@ -16,14 +20,109 @@ import (
|
|||||||
//go:embed index.html
|
//go:embed index.html
|
||||||
var indexHTML []byte
|
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 {
|
type Server struct {
|
||||||
tracker *status.Tracker
|
tracker *status.Tracker
|
||||||
log *logger.Logger
|
log *logger.Logger
|
||||||
|
store *settings.Store
|
||||||
|
controls Controls
|
||||||
|
durCache *durationCache
|
||||||
inputDir string
|
inputDir string
|
||||||
|
failedDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(tracker *status.Tracker, log *logger.Logger, inputDir string) *Server {
|
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, inputDir: inputDir}
|
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
|
// Handler returns the mux for all status routes. Phase 3 adds "/" (the HTML
|
||||||
@@ -31,10 +130,105 @@ func New(tracker *status.Tracker, log *logger.Logger, inputDir string) *Server {
|
|||||||
func (s *Server) Handler() http.Handler {
|
func (s *Server) Handler() http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/status", s.handleStatus)
|
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)
|
mux.HandleFunc("/", s.handleIndex)
|
||||||
return mux
|
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) {
|
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != "/" {
|
if r.URL.Path != "/" {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
@@ -45,27 +239,62 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type statusResponse struct {
|
type statusResponse struct {
|
||||||
|
Running bool `json:"running"`
|
||||||
Current status.Snapshot `json:"current"`
|
Current status.Snapshot `json:"current"`
|
||||||
Queue []string `json:"queue"`
|
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"`
|
Recent []logger.LogEntry `json:"recent"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
queue := []string{}
|
|
||||||
for _, f := range watcher.InputFiles(s.inputDir) {
|
|
||||||
queue = append(queue, filepath.Base(f))
|
|
||||||
}
|
|
||||||
|
|
||||||
recent, err := s.log.RecentLogs(50)
|
recent, err := s.log.RecentLogs(50)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "reading logs", http.StatusInternalServerError)
|
http.Error(w, "reading logs", http.StatusInternalServerError)
|
||||||
return
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(statusResponse{
|
_ = json.NewEncoder(w).Encode(statusResponse{
|
||||||
Current: s.tracker.Snapshot(),
|
Running: s.controls.Running(),
|
||||||
Queue: queue,
|
Current: snap,
|
||||||
|
Queue: baseNames(files),
|
||||||
|
Failed: baseNames(watcher.InputFiles(s.failedDir)),
|
||||||
|
QueueETASec: queueETASeconds(snap.ETASec, sumQueued, snap.Speed),
|
||||||
|
QueueETAPartial: partial,
|
||||||
Recent: recent,
|
Recent: recent,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func baseNames(paths []string) []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, p := range paths {
|
||||||
|
out = append(out, filepath.Base(p))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (0–63)</th><th>Preset (0–13)</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>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,6 +53,9 @@ type Tracker struct {
|
|||||||
fps float64
|
fps float64
|
||||||
speed float64
|
speed float64
|
||||||
startedAt time.Time
|
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.
|
// Snapshot is an immutable view of the tracker for readers.
|
||||||
@@ -64,6 +67,7 @@ type Snapshot struct {
|
|||||||
Percent float64 `json:"percent"`
|
Percent float64 `json:"percent"`
|
||||||
FPS float64 `json:"fps"`
|
FPS float64 `json:"fps"`
|
||||||
Speed float64 `json:"speed"`
|
Speed float64 `json:"speed"`
|
||||||
|
Paused bool `json:"paused"`
|
||||||
ElapsedSec int `json:"elapsed_sec"`
|
ElapsedSec int `json:"elapsed_sec"`
|
||||||
ETASec int `json:"eta_sec"`
|
ETASec int `json:"eta_sec"`
|
||||||
StartedAt time.Time `json:"started_at"`
|
StartedAt time.Time `json:"started_at"`
|
||||||
@@ -83,6 +87,26 @@ func (t *Tracker) Begin(file string) {
|
|||||||
t.streams = nil
|
t.streams = nil
|
||||||
t.totalSec, t.outTime, t.fps, t.speed = 0, 0, 0, 0
|
t.totalSec, t.outTime, t.fps, t.speed = 0, 0, 0, 0
|
||||||
t.startedAt = time.Now()
|
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) {
|
func (t *Tracker) SetPhase(p string) {
|
||||||
@@ -129,6 +153,9 @@ func (t *Tracker) Idle() {
|
|||||||
t.streams = nil
|
t.streams = nil
|
||||||
t.totalSec, t.outTime, t.fps, t.speed = 0, 0, 0, 0
|
t.totalSec, t.outTime, t.fps, t.speed = 0, 0, 0, 0
|
||||||
t.startedAt = time.Time{}
|
t.startedAt = time.Time{}
|
||||||
|
t.paused = false
|
||||||
|
t.pausedAt = time.Time{}
|
||||||
|
t.pausedTotal = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Tracker) Snapshot() Snapshot {
|
func (t *Tracker) Snapshot() Snapshot {
|
||||||
@@ -141,10 +168,18 @@ func (t *Tracker) Snapshot() Snapshot {
|
|||||||
Streams: t.streams,
|
Streams: t.streams,
|
||||||
FPS: t.fps,
|
FPS: t.fps,
|
||||||
Speed: t.speed,
|
Speed: t.speed,
|
||||||
|
Paused: t.paused,
|
||||||
StartedAt: t.startedAt,
|
StartedAt: t.startedAt,
|
||||||
}
|
}
|
||||||
if !t.startedAt.IsZero() {
|
if !t.startedAt.IsZero() {
|
||||||
s.ElapsedSec = int(time.Since(t.startedAt).Seconds())
|
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 {
|
if t.totalSec > 0 {
|
||||||
s.Percent = t.outTime / t.totalSec * 100
|
s.Percent = t.outTime / t.totalSec * 100
|
||||||
|
|||||||
@@ -81,6 +81,27 @@ func TestSnapshotPercentAndETA(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestSnapshotIdleNoDivByZero(t *testing.T) {
|
||||||
s := New().Snapshot() // no Begin, totalSec 0
|
s := New().Snapshot() // no Begin, totalSec 0
|
||||||
if s.Percent != 0 || s.ETASec != 0 || s.Phase != PhaseIdle {
|
if s.Percent != 0 || s.ETASec != 0 || s.Phase != PhaseIdle {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"av1dae/pkg/types"
|
"av1dae/pkg/types"
|
||||||
@@ -59,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).
|
||||||
@@ -68,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
|
||||||
@@ -79,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()
|
||||||
@@ -146,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{
|
||||||
|
|||||||
Reference in New Issue
Block a user