github derekshreds/Snacks v2.8.2
Snacks v2.8.2

latest release: v2.9.0
8 hours ago

Snacks v2.8.2

Automated Video Library Encoder

A patch release that closes the "encode → no savings → re-encode forever" loop and the cluster mux-pass disappearance bug it was masking. A new MediaFileStatus.NoSavings (and matching WorkItemStatus.NoSavings) splits predicted skips from empirical "we already ran ffmpeg and the output didn't shrink." Re-evaluate's Skipped → Unseen sweep no longer touches NoSavings rows by default — the user opts in via a new "Also retry encodes that previously produced no savings" checkbox on the Re-evaluate panel when they've actually changed encoder settings. The cluster path's silent-skip channel is fixed too: ConvertVideoForRemoteAsync now passes skipPlacement: true so a worker no longer renames its own [snacks]-tagged file out of existence (which made GetOutputFileForJob's *[snacks]* glob return null, falsely reporting noSavings = true and dropping a perfectly good remux into Skipped). The dispatcher's pre-flight skip check (FinaliseForDispatchAsync) now reads the persisted DB row when one exists instead of synthesising from WorkItem alone — DB-restored items had null AudioStreams/SubtitleStreams, so the HasMuxableWork ladder fell through to the bitrate gate and silently marked at-target HEVC files with non-English audio that needed muxing as Skipped (diverging from Re-evaluate's verdict on the same row). Serilog is now wired as the host logger with a rolling daily file under ${SNACKS_WORK_DIR}/logs/snacks-*.log, surfaced through a read-only GET /api/diagnostics/log endpoint — every silent-skip path (DispatchSkipped, RemoteEncodeNoSavings worker + master, RemoteEncodeKept, WatchdogAction, DownloadAttemptFailed/DownloadRetriesExhausted, ReevaluateRun) now writes a structured event so the next "queue items vanished overnight" report has a persistent record instead of dead Console.WriteLine calls.


The "no savings" loop

New NoSavings status, distinct from Skipped

  • MediaFileStatus.NoSavings + WorkItemStatus.NoSavings — encoding ran, output was produced, the keep predicate said "discard." Previously this collapsed into Completed on the work item and Skipped on the DB row, which the queue tile (reading WorkItem.Status) and the re-evaluate sweep (reading MediaFile.Status) disagreed about. Now both sides write NoSavings and the tile renders the same source → encoded size delta as a kept encode, just with the +/0% instead of a negative percent.
  • WorkItem.IsTerminal extended to include NoSavings — a stale progress callback (e.g. a heartbeat re-fire after the master has already finalized) can't flip a NoSavings tile back to Uploading/Downloading/Processing. Pinned by a unit test in NoSavingsStickyOutcomeTests.
  • MediaFile.LastEncodedAt (new column) — UTC stamp of the most recent encode attempt's terminal outcome, set by both keep and no-savings completion paths in HandleRemoteCompletion and the local pipeline. Distinct from CompletedAt which only ticks on successful keeps. Migration 20260502160818_AddNoSavingsStatusAndLastEncodedAt adds the column and is auto-applied at startup.

Re-evaluate respects empirical outcomes

  • ReevaluateSkippedAsync no longer touches NoSavings rows — that was the loop closure: the same bitrate-prediction logic that originally queued the file was flipping the row back to Unseen after every Re-evaluate click, the next scan re-queued it, the encoder produced the same no-savings output, the cycle repeated. The empirical outcome now wins by default.
  • Opt-in forceRetryNoSavings toggle — new "Also retry encodes that previously produced no savings" checkbox under the Re-evaluate Queue button on the Advanced Settings panel. Off by default. When ticked, the POST /api/settings/reevaluate?forceRetryNoSavings=true query flag invokes ReevaluateNoSavingsAsync() which flips every NoSavings row back to Unseen so the next library scan re-queues them — the right escape hatch for "I just changed the target bitrate, retry the rows that previously didn't shrink."
  • Per-row "Try again" button on NoSavings queue tiles — the action group on a NoSavings work item adds a single-row retry button next to the log button. Lets the user request a single-file retry under current settings without flipping every NoSavings row in the library at once.

Empirical-outcome stamping in completion paths

  • SetStatusAndLastEncodedAtAsync (new repo method) — atomic status + LastEncodedAt write used by every completion path that produces an empirical outcome (local keep, local no-savings, remote keep, remote no-savings). MediaFileStatus.Completed also stamps CompletedAt for the existing dashboard surface.
  • ClearRemoteAssignmentAsync stamps LastEncodedAt — when the cluster path clears a remote assignment with a terminal status (Completed/NoSavings/Skipped), the same anchor is set so re-evaluate can reason about freshness regardless of which path produced the outcome.
  • WorkItem.LastEncodeProducedNoSavings (in-memory flag)ConvertVideoAsync's no-keep branch flips this so the local ProcessQueueAsync completion path knows whether to write NoSavings vs Completed. Deliberately not persisted — the DB carries the outcome via MediaFile.Status/WorkItem is rebuilt on restart.

Cluster mux-pass disappearance fix

Worker no longer runs HandleOutputPlacement

  • ConvertVideoForRemoteAsync passes skipPlacement: true — before, a worker with an EncoderOptions.DeleteOriginalFile = true config would run HandleOutputPlacement in its own per-job temp dir, which calls GetCleanOutputName and renames {base} [snacks].{ext}{base}.{ext}. The master then asked the worker for the output via ClusterNodeJobService.GetOutputFileForJob, whose *[snacks]* glob now matched nothing and returned null. That null flowed back to the master as noSavings = true, and the encoded output was reaped with the worker's temp dir while the original sat untouched. The worker now leaves the [snacks] tag on disk and lets the master place the file after download — placement is the master's job, since only the master has the user's library mounted.
  • ConvertVideoAsync accepts skipPlacement and threads it through every retry branch in HandleConversionFailure — the conservative-hw retry, the strip-subs retry, the sw-decode retry, the drop-image-subs retry, and the final fallback all forward the flag, so a worker that hits a retry path doesn't suddenly run placement on the retried output either.
  • GetCleanOutputName is now internal static — needed so the regression test in RemoteConversionPlacementTests can document the rename behavior that bit on the worker (the *[snacks]* glob returning empty after the strip is the smoking gun).

Pre-flight skip check honors persisted state

  • FinaliseForDispatchAsync prefers the DB row over a synthetic — items restored from DB on startup are queued lazily with no probe, so a synthetic MediaFile built from WorkItem alone had null AudioStreams/SubtitleStreams. HasMuxableWork saw no work, the skip ladder fell through to the bitrate gate, and an at-target HEVC file with a non-English audio track that needed muxing was silently marked Skipped here — diverging from Re-evaluate, which examined the same DB row and correctly said "still needs to encode." The function now uses the DB row directly when one exists (merging in the just-resolved OriginalLanguage) and falls back to the synthetic only on the force-add path where there's no DB row at all.

Operations log

Serilog as the host logger

  • Program.cs configures Serilog — rolling daily file at ${SNACKS_WORK_DIR}/logs/snacks-*.log with a 10MB cap per file and 7 retained rolls (≈ a week of activity at the cluster path's volume). Console sink kept for foreground runs. Output template includes timestamp + level + source context + structured properties so future ops queries can grep on field name. ${SNACKS_WORK_DIR} falls back to LocalApplicationData/Snacks/work when the env var isn't set.
  • GET /api/diagnostics/log?lines=N — read-only tail endpoint surfaces the latest log file without ssh-ing into the host. lines clamped to [1, 5000]. Reads with FileShare.ReadWrite so it doesn't fight Serilog's writer; the rolling cap means worst-case a single request touches ≈ 10MB.

Structured events on every silent-skip path

  • DispatchSkipped — pre-flight skip decision. Fields: jobId, fileName, reason. Captures items dropped before dispatch ever reaches the worker.
  • RemoteEncodeNoSavings (source=worker and source=master) — worker-reported and master-recomputed no-savings outcomes are logged separately so the ops log shows which side made the call. Fields: source, jobId, fileName, sourceSize, outputSize (master only), videoCopy, nodeBaseUrl (worker only).
  • RemoteEncodeKept — successful master-side keep. Fields: jobId, fileName, sourceSize, outputSize, reason (savings/audio-fanout/video-copy-mux), videoCopy. Pairs with the no-savings event so the ratio of kept-vs-discarded encodes is queryable.
  • WatchdogAction — every watchdog branch (ghost-node, local-orphan, stalled-remote) writes a structured warning. Fields: case, jobId, fileName, plus silentMinutes / nodeName / ghostNodeId per case.
  • DownloadAttemptFailed / DownloadRetriesExhausted — per-attempt warnings during the download retry ladder, plus a loud Error-level event when the retry budget is gone. The exhausted event is the channel where a worker→master reverse-network misconfiguration silently re-encodes the same file forever; without it, the user has no surface signal at all.
  • ReevaluateRun — every Re-evaluate click writes one summary line with requeued, reskipped, dequeued, retriedNoSavings, and forceRetryNoSavings, so a future "Re-evaluate flagged 1650 items as needing rescan" report has historical context to compare against.

Files Changed

NoSavings status + Re-evaluate opt-in

  • Snacks/Models/MediaFile.csMediaFileStatus.NoSavings enum value; LastEncodedAt column
  • Snacks/Models/WorkItem.csWorkItemStatus.NoSavings enum value (terminal); LastEncodeProducedNoSavings in-memory flag
  • Snacks/Data/Migrations/20260502160818_AddNoSavingsStatusAndLastEncodedAt.cs (new) — adds LastEncodedAt column
  • Snacks/Data/MediaFileRepository.csSetStatusAndLastEncodedAtAsync (new); ReevaluateNoSavingsAsync (new); ClearRemoteAssignmentAsync stamps LastEncodedAt on terminal statuses
  • Snacks/Controllers/SettingsController.csReevaluate accepts forceRetryNoSavings query flag, returns retriedNoSavings in the response, writes the ReevaluateRun summary log
  • Snacks/Views/Shared/_AdvancedSettings.cshtml — "Also retry encodes that previously produced no savings" checkbox under the Re-evaluate button
  • Snacks/wwwroot/js/api.jssettingsApi.reevaluate({ forceRetryNoSavings }) query flag wiring
  • Snacks/wwwroot/js/queue/work-item-renderer.jsNoSavings status name (code 8); shared meta-line render with Completed; per-row "Try again" button
  • Snacks/wwwroot/js/settings/panels/advanced-panel.js — reads the checkbox, surfaces retriedNoSavings count in the toast
  • Snacks.Tests/Pipeline/NoSavingsStickyOutcomeTests.cs (new) — covers Reevaluate ignoring NoSavings by default, the opt-in flip, the LastEncodedAt stamp, and WorkItem.IsTerminal rejecting active-state flips on a NoSavings row

Cluster mux-pass disappearance

  • Snacks/Services/TranscodingService.csConvertVideoAsync accepts skipPlacement; ConvertVideoForRemoteAsync passes skipPlacement: true; HandleConversionFailure threads the flag through every retry branch; GetCleanOutputName promoted to internal static
  • Snacks/Services/TranscodingService.csFinaliseForDispatchAsync uses the DB row over the synthetic when one exists (merges in OriginalLanguage)
  • Snacks/Services/TranscodingService.cs — local ProcessQueueAsync completion writes NoSavings vs Completed based on LastEncodeProducedNoSavings; HandleRemoteCompletion writes NoSavings on the master-recomputed-discard branch
  • Snacks/Services/ClusterService.cs — worker-reported no-savings branch now writes WorkItemStatus.NoSavings + MediaFileStatus.NoSavings (was Completed + Skipped)
  • Snacks.Tests/Cluster/RemoteConversionPlacementTests.cs (new) — documents the [snacks]-tag-strip rename that hid the file from the worker's glob; locks in ConvertVideoForRemoteAsync calling ConvertVideoAsync with skipPlacement: true

Operations log

  • Snacks/Snacks.csprojSerilog.AspNetCore and Serilog.Sinks.File package references
  • Snacks/Program.cs — Serilog configuration; rolling daily file under ${SNACKS_WORK_DIR}/logs/
  • Snacks/Controllers/DiagnosticsController.cs (new) — GET /api/diagnostics/log?lines=N read-only tail endpoint
  • Snacks/Services/TranscodingService.csILogger<TranscodingService> injected; DispatchSkipped and master-side RemoteEncodeKept / RemoteEncodeNoSavings events
  • Snacks/Services/ClusterService.csILogger<ClusterService> injected; worker-side RemoteEncodeNoSavings, DownloadAttemptFailed, DownloadRetriesExhausted, WatchdogAction events
  • Snacks/Controllers/SettingsController.csReevaluateRun summary event

Version bumps

  • Snacks/Controllers/HomeController.cs — health endpoint version
  • Snacks/Services/ClusterDiscoveryService.csClusterVersion protocol bump to 2.8.2
  • Snacks/Views/Shared/_Layout.cshtml — footer version
  • README.md — badge + footer
  • build-and-export.bat — Docker tag version
  • electron-app/package.json / package-lock.json

Full documentation: README.md

Don't miss a new Snacks release

NewReleases is sending notifications on new releases.