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 intoCompletedon the work item andSkippedon the DB row, which the queue tile (readingWorkItem.Status) and the re-evaluate sweep (readingMediaFile.Status) disagreed about. Now both sides writeNoSavingsand the tile renders the same source → encoded size delta as a kept encode, just with the+/0%instead of a negative percent.WorkItem.IsTerminalextended to includeNoSavings— a stale progress callback (e.g. a heartbeat re-fire after the master has already finalized) can't flip aNoSavingstile back toUploading/Downloading/Processing. Pinned by a unit test inNoSavingsStickyOutcomeTests.MediaFile.LastEncodedAt(new column) — UTC stamp of the most recent encode attempt's terminal outcome, set by both keep and no-savings completion paths inHandleRemoteCompletionand the local pipeline. Distinct fromCompletedAtwhich only ticks on successful keeps. Migration20260502160818_AddNoSavingsStatusAndLastEncodedAtadds the column and is auto-applied at startup.
Re-evaluate respects empirical outcomes
ReevaluateSkippedAsyncno longer touchesNoSavingsrows — that was the loop closure: the same bitrate-prediction logic that originally queued the file was flipping the row back toUnseenafter 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
forceRetryNoSavingstoggle — 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, thePOST /api/settings/reevaluate?forceRetryNoSavings=truequery flag invokesReevaluateNoSavingsAsync()which flips everyNoSavingsrow back toUnseenso 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
NoSavingsqueue tiles — the action group on aNoSavingswork 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 everyNoSavingsrow in the library at once.
Empirical-outcome stamping in completion paths
SetStatusAndLastEncodedAtAsync(new repo method) — atomic status +LastEncodedAtwrite used by every completion path that produces an empirical outcome (local keep, local no-savings, remote keep, remote no-savings).MediaFileStatus.Completedalso stampsCompletedAtfor the existing dashboard surface.ClearRemoteAssignmentAsyncstampsLastEncodedAt— 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 localProcessQueueAsynccompletion path knows whether to writeNoSavingsvsCompleted. Deliberately not persisted — the DB carries the outcome viaMediaFile.Status/WorkItemis rebuilt on restart.
Cluster mux-pass disappearance fix
Worker no longer runs HandleOutputPlacement
ConvertVideoForRemoteAsyncpassesskipPlacement: true— before, a worker with anEncoderOptions.DeleteOriginalFile = trueconfig would runHandleOutputPlacementin its own per-job temp dir, which callsGetCleanOutputNameand renames{base} [snacks].{ext}→{base}.{ext}. The master then asked the worker for the output viaClusterNodeJobService.GetOutputFileForJob, whose*[snacks]*glob now matched nothing and returnednull. Thatnullflowed back to the master asnoSavings = 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.ConvertVideoAsyncacceptsskipPlacementand threads it through every retry branch inHandleConversionFailure— 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.GetCleanOutputNameis nowinternal static— needed so the regression test inRemoteConversionPlacementTestscan 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
FinaliseForDispatchAsyncprefers the DB row over a synthetic — items restored from DB on startup are queued lazily with no probe, so a syntheticMediaFilebuilt fromWorkItemalone hadnullAudioStreams/SubtitleStreams.HasMuxableWorksaw 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 markedSkippedhere — 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-resolvedOriginalLanguage) 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.csconfigures Serilog — rolling daily file at${SNACKS_WORK_DIR}/logs/snacks-*.logwith 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 toLocalApplicationData/Snacks/workwhen 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.linesclamped to[1, 5000]. Reads withFileShare.ReadWriteso 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=workerandsource=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, plussilentMinutes/nodeName/ghostNodeIdper case.DownloadAttemptFailed/DownloadRetriesExhausted— per-attempt warnings during the download retry ladder, plus a loudError-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 withrequeued,reskipped,dequeued,retriedNoSavings, andforceRetryNoSavings, 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.cs—MediaFileStatus.NoSavingsenum value;LastEncodedAtcolumnSnacks/Models/WorkItem.cs—WorkItemStatus.NoSavingsenum value (terminal);LastEncodeProducedNoSavingsin-memory flagSnacks/Data/Migrations/20260502160818_AddNoSavingsStatusAndLastEncodedAt.cs(new) — addsLastEncodedAtcolumnSnacks/Data/MediaFileRepository.cs—SetStatusAndLastEncodedAtAsync(new);ReevaluateNoSavingsAsync(new);ClearRemoteAssignmentAsyncstampsLastEncodedAton terminal statusesSnacks/Controllers/SettingsController.cs—ReevaluateacceptsforceRetryNoSavingsquery flag, returnsretriedNoSavingsin the response, writes theReevaluateRunsummary logSnacks/Views/Shared/_AdvancedSettings.cshtml— "Also retry encodes that previously produced no savings" checkbox under the Re-evaluate buttonSnacks/wwwroot/js/api.js—settingsApi.reevaluate({ forceRetryNoSavings })query flag wiringSnacks/wwwroot/js/queue/work-item-renderer.js—NoSavingsstatus name (code8); shared meta-line render withCompleted; per-row "Try again" buttonSnacks/wwwroot/js/settings/panels/advanced-panel.js— reads the checkbox, surfacesretriedNoSavingscount in the toastSnacks.Tests/Pipeline/NoSavingsStickyOutcomeTests.cs(new) — coversReevaluateignoringNoSavingsby default, the opt-in flip, theLastEncodedAtstamp, andWorkItem.IsTerminalrejecting active-state flips on aNoSavingsrow
Cluster mux-pass disappearance
Snacks/Services/TranscodingService.cs—ConvertVideoAsyncacceptsskipPlacement;ConvertVideoForRemoteAsyncpassesskipPlacement: true;HandleConversionFailurethreads the flag through every retry branch;GetCleanOutputNamepromoted tointernal staticSnacks/Services/TranscodingService.cs—FinaliseForDispatchAsyncuses the DB row over the synthetic when one exists (merges inOriginalLanguage)Snacks/Services/TranscodingService.cs— localProcessQueueAsynccompletion writesNoSavingsvsCompletedbased onLastEncodeProducedNoSavings;HandleRemoteCompletionwritesNoSavingson the master-recomputed-discard branchSnacks/Services/ClusterService.cs— worker-reported no-savings branch now writesWorkItemStatus.NoSavings+MediaFileStatus.NoSavings(wasCompleted+Skipped)Snacks.Tests/Cluster/RemoteConversionPlacementTests.cs(new) — documents the[snacks]-tag-strip rename that hid the file from the worker's glob; locks inConvertVideoForRemoteAsynccallingConvertVideoAsyncwithskipPlacement: true
Operations log
Snacks/Snacks.csproj—Serilog.AspNetCoreandSerilog.Sinks.Filepackage referencesSnacks/Program.cs— Serilog configuration; rolling daily file under${SNACKS_WORK_DIR}/logs/Snacks/Controllers/DiagnosticsController.cs(new) —GET /api/diagnostics/log?lines=Nread-only tail endpointSnacks/Services/TranscodingService.cs—ILogger<TranscodingService>injected;DispatchSkippedand master-sideRemoteEncodeKept/RemoteEncodeNoSavingseventsSnacks/Services/ClusterService.cs—ILogger<ClusterService>injected; worker-sideRemoteEncodeNoSavings,DownloadAttemptFailed,DownloadRetriesExhausted,WatchdogActioneventsSnacks/Controllers/SettingsController.cs—ReevaluateRunsummary event
Version bumps
Snacks/Controllers/HomeController.cs— health endpoint versionSnacks/Services/ClusterDiscoveryService.cs—ClusterVersionprotocol bump to 2.8.2Snacks/Views/Shared/_Layout.cshtml— footer versionREADME.md— badge + footerbuild-and-export.bat— Docker tag versionelectron-app/package.json/package-lock.json
Full documentation: README.md