Snacks v2.8.0
Automated Video Library Encoder
A minor release headlined by per-language audio fan-out — the legacy single-codec / single-bitrate / 2-channel-toggle audio model is replaced with a list of AudioOutputProfile rows ("for each kept language, emit AAC stereo + Opus 5.1, plus a copy of the source") and a PreserveOriginalAudio master toggle. The release also lands a manual queue re-evaluation flow: settings saves no longer trigger a library walk on every keystroke; instead a Re-evaluate Queue button on Advanced Settings runs the walk on demand, in both directions (Skipped → Unseen for newly eligible files, Unseen → Skipped for files that no longer qualify, plus a pass that drops obsolete pending items). Original-language lookups are now cached on a new OriginalLanguage column and resolved up front in every skip-ladder, so analyze, scan, and dispatch all see the same merged keep lists. HDR is now a first-class scan column, fed into the cached WouldEncodeBeNoOp predicate so tonemap-on users don't see analyze report Skip while the queue would actually encode. The cluster dispatcher gains a pre-dispatch skip gate (mirroring the local one) plus a same-source double-dispatch guard, the CPU fallback now triggers when a node's hardware can't handle the codec (instead of deadlocking on an iGPU that lacks AV1 encode), and the override dialog grows per-language audio/subtitle keep-list overrides. Plus a Re-Evaluation Lock so concurrent button-mashes return 409 instead of racing, an Unraid Community Applications template, and a regression test suite spanning ~7,000 lines across audio, subtitle, video, pipeline, settings, and override paths.
New Features
Per-Language Audio Fan-Out
- New
AudioOutputProfilemodel — replaces the legacy single-codec / single-bitrate /TwoChannelAudiotrio with a list of{Codec, Layout, BitrateKbps}rows (codec ∈aac/ac3/eac3/opus; layout ∈Source/Mono/Stereo/5.1/7.1;BitrateKbps=0means "use the codec default" — 192 for AAC/EAC3/Opus, 448 for AC3). For every language inAudioLanguagesToKeep, the planner emits one stream per profile row, picking the best matching source track and downmixing higher channel layouts when needed. Rows that already exist as-is in the source are deduped to a copy automatically so a "want AAC stereo English" + a source that already has AAC stereo English doesn't produce two identical streams. PreserveOriginalAudiotoggle — pass every kept source track through with-c:a copyalongside any encoded variants. Defaults totrueso out-of-the-box behavior remains "remux, don't re-encode". Combined with an emptyAudioOutputslist, this reproduces the legacy "copy everything" path; combined with profile rows, it produces the originals + the fan-out.- Audio-output growth is exempt from the savings gate — configured fan-out can grow the file past any video savings (the user opted into that growth), so the post-encode "discard if no savings" check is skipped when the user has any
AudioOutputsrow configured. The size log line reports+Δ kept due to configured audio outputsinstead of the savings format. The remote (cluster) completion path mirrors the same gate so a worker doesn't discard an output the master would have kept. - Audio track titles include the layout — the encoded-stream title written by
AppendAudioMetais nowLanguage (Codec Layout)instead ofLanguage (Codec)(e.g.English (Opus 5.1)instead ofEnglish (Opus)), so a player's track picker can disambiguate two outputs of the same codec at different channel counts. Channels → label resolves toMono/Stereo/5.1/7.1, with a{N}chfallback for unusual counts. Copy passes are unchanged — their metadata flows through with-c:a copy. - Encoded streams now carry full metadata —
AppendAudioMetawriteslanguage=<3-letter-tag>plus the descriptive title to every re-encoded output, so a downmixed stream no longer inherits the source's stale title (which might still say5.1 Surroundafter a stereo downmix). Copies are intentionally left alone —-c:a copypreserves the source's language + title metadata, and overriding would clobber descriptive titles likeSurround 5.1on the original tracks. - Lossless-source priority for re-encode picks — when multiple source tracks could satisfy a re-encode profile,
SourceCodecQualityranks lossless formats above lossy (flac/truehd/mlp/alac/pcmrank highest, thendts/dtshd, theneac3/ac3, thenaac/opus/vorbis/mp3) so a 5.1 TrueHD wins over a 5.1 AC3 as the encode source for a 5.1 Opus output. - Legacy settings keep working through a one-shot migration —
EncoderOptions.ApplyLegacyAudioMigrationtranslates an on-diskAudioCodec+AudioBitrateKbps+TwoChannelAudioconfig into the newPreserveOriginalAudio+AudioOutputsshape (copy→Preserve=true, Outputs=[]; non-copy + 2ch →Preserve=false, Outputs=[{codec, "Stereo", bitrate}]; non-copy without 2ch →Preserve=false, Outputs=[{codec, "Source", bitrate}]). The migration is presence-aware: once the saved JSON contains eitherPreserveOriginalAudioorAudioOutputs, the user's settings are authoritative and migration never runs again — even if they save with an emptyAudioOutputslist. Without this, the "delete all rows → AAC keeps coming back on the next load" loop persisted indefinitely. Folder/node overrides that touch only the legacy fields trigger the same migration on the override clone so old per-folder configs continue to take effect.
Manual Queue Re-Evaluation
- Settings saves no longer trigger a library walk —
POST /api/settingsis now a synchronous "write file + bump in-memory options" call. The previous behavior (re-evaluate every Skipped row on every save) was a heavyweight DB walk that fired on every keystroke of an auto-saving form, with visible UI lag. - New
POST /api/settings/reevaluateendpoint + Re-evaluate Queue button — runs the library walk on demand. The Advanced Settings panel grows a Re-evaluate Queue button that disables itself for the duration. Three passes:- Direction A — Skipped → Unseen newly eligible rows under current settings get flipped back to Unseen so the next scan re-probes them. Legacy rows without stream summaries (
AudioStreams/SubtitleStreamsboth null) are also flipped so they get re-probed. - Direction B — Unseen → Skipped new in this release. Rows the current settings would skip get flipped back to Skipped. Catches the case where the user added an audio output that re-queued a batch, then removed it — without this direction, those files would stay queued forever.
- Pending-queue cleanup —
RemoveSettingsObsoletedQueueItemsAsyncwalks the in-memory work queue and drops items whose cachedMediaFilerow would now be skipped under current options (with a take-snapshot, re-evaluate-async, re-acquire-lock-for-removal pattern so concurrent dequeues don't race). The dropped rows are SignalR-notified to all clients (WorkItemRemoved) and persisted toMediaFileStatus.Skipped.
- Direction A — Skipped → Unseen newly eligible rows under current settings get flipped back to Unseen so the next scan re-probes them. Legacy rows without stream summaries (
- Original-language pre-pass before re-evaluating — the re-evaluate endpoint backfills missing
OriginalLanguagevalues across Skipped + Unseen + Queued rows before running the predicates, soWouldSkipUnderOptionssees the same merged keep listsConvertVideoAsyncwould build at encode time. Cheap when the integration provider caches per-show / per-movie; without it, the ladder mis-predicts drops on files queued before the cache existed. - Process-wide concurrency lock — a static
SemaphoreSlim(1, 1)inSettingsControllerrejects a second re-evaluate request with HTTP 409 instead of queueing or coalescing. The operation is idempotent at the per-row level, but two simultaneous walks would race on the queue mutation and waste DB work. The user-facing behavior is "the button disables itself, you can click it again when it finishes."
Original-Language Caching
- New
OriginalLanguagecolumn onMediaFile— ISO 639-1 (2-letter) code resolved via the configuredOriginalLanguageProvider(Sonarr / Radarr / TVDB / TMDb) at scan time. Cached so the scan-phase skip predicates and the analyze dry-run see the same merged keep-list thatConvertVideoAsyncwould build at encode time, without re-querying the integration provider on every settings save / re-eval / dispatch. New EF Core migration20260430214143_AddMediaFileOriginalLanguageadds the column. - Resolved up front in every skip ladder —
ResolveOriginalLanguageAsyncis now called eagerly inAddFileAsync,AnalyzeFileAsync, the local dispatcher's pre-encodeFinaliseForDispatchAsync, and the cluster master'sCloneOptionsForWorkerAsync. All four sites first check the cachedMediaFile.OriginalLanguage, fall back to a live integration lookup if null, then persist the result so subsequent passes are cache hits. Ends the "analyze says Skip but the queue runs an encode" mismatch caused byKeepOriginalLanguagebeing resolved late inConvertVideoAsync. - The
KeepOriginalLanguagelookup moved out ofConvertVideoAsync— the per-encode lookup that used to live inConvertVideoAsyncis gone;optionsarrive at the encoder already merged. The matching cluster master path also persists newly resolved values, so a worker-bound dispatch now warms the cache for every subsequent decision about that file. - Pre-dispatch finalisation is mandatory — both the local dispatcher and the cluster master run
FinaliseForDispatchAsync(which resolves OriginalLanguage live, merges into per-job options, re-runsWouldSkipUnderOptions) before claiming a slot. Catches three cases the queue couldn't pre-vet: legacy DB rows queued before the cache existed, settings flipped between queue add and dispatch, and force-adds that bypass parts ofAddFileAsync's ladder. Cancelled/Stopped status flips that happen during the awaits inside finalisation are detected and the dispatch is dropped before any upload begins.
HDR as a First-Class Scan Column
- New
IsHdrcolumn onMediaFile— populated byFfprobeService.IsHdr(PQ / HLG / Dolby Vision detection) at scan time and cached on the row. New EF Core migration20260430222629_AddMediaFileIsHdradds the column. WouldEncodeBeNoOpreads cached HDR — the no-op skip gate inAnalyzeFileAsyncpreviously hard-codedisHdr=falsewhen only the cached row was available (no live probe). On a tonemap-on user, this caused analyze to predict Skip whileAddFileAsyncwould queue for encode (the active filter forces re-encode). The gate now readsdbFile.IsHdrwhen no live probe is available, keeping analyze and the live ladder in sync.
Folder Override Resolver Wiring
- Local dispatcher applies per-folder overrides at encode time — a new
Func<string, EncoderOptionsOverride?>resolver wired byAutoScanServiceletsTranscodingService.ProcessQueueAsyncapply per-folder overrides to the per-job options clone. Without this, a folder configured to encode at a different codec / target / language was queued correctly under the override (the auto-scanner used it for skip decisions) but encoded under the global settings. The wiring sidesteps the TranscodingService ↔ AutoScanService DI cycle by injecting the resolver after construction.
Audio Bitrate Sniffing for Analyze
- Analyze now re-measures video-only bitrate the same way
AddFileAsyncdoes — the analyze dry-run's bitrate calculation used to be a flatfile_size × 8 / duration, which inflates with chunky audio. When the total is within 2× the effective bitrate target, analyze now runs the same 15-second copy-pass video-only re-measurement thatAddFileAsyncuses. Without this, a file with chunky audio would predict Queue (over target by total bitrate) whileAddFileAsyncwould re-measure to a video-only number under target and Skip — analyze and the live ladder disagreed.
Override Dialog Updates
- Audio overrides moved to the new shape — the legacy
AudioCodec/AudioBitrateKbps/TwoChannelAudiotoggles are gone from the override dialog. Replaced withPreserveOriginalAudio(bool) andAudioOutputs(a chip-style list with the same Codec / Layout / Bitrate row template the global settings use; the override fully replaces the base list rather than merging). The legacy fields remain on the model so old saved overrides keep loading, andEncoderOptionsOverride.ApplyOverridesmigrates them in place if a saved override only touched the old fields. - Per-language keep-list overrides —
AudioLanguagesToKeepandSubtitleLanguagesToKeepare now overridable at the folder and node level, with a chip-input widget that mirrors the global settings UI. Empty list is a valid override (interpreted as "keep all"). Useful for "this folder is a foreign-language library; keep just the original-language tracks" without touching global settings.
CPU Fallback for Codec-Incompatible Hardware
- Cluster scoring now considers codec compatibility per device — under
auto, a node with hardware that can't encode the requested codec (e.g. an Intel iGPU asked for AV1, or a card with the AV1 device disabled) used to lock out CPU dispatch outright because the master saw "node has hardware → never CPU." The check is now codec-aware: CPU is only excluded when the node has usable hardware for that codec — present, enabled, and listed in the device'sSupportedCodecs. Without this, an AV1 job on an iGPU-only node would never get scheduled.
Bug Fixes & Reliability
- Same-source double-dispatch guard on the cluster master — the dispatcher already de-duplicates by
WorkItem.Id, but two distinct work items pointing at the same source path could both dispatch and race throughFinalizeCompletionAsync, with the second one tripping the source-existence check after the first replaced the original ("Source file was removed during encoding"). The dispatcher now snapshots every in-flight source path (assigned + uploading + downloading) at the start of each pass and skips items whose path is already in flight. - Remote-job cleanup happens before source-replacing side effects —
FinalizeCompletionAsyncnow removes the job from_remoteJobsbeforeHandleRemoteCompletionruns. Without this, a heartbeat-driven re-fire of the completion handler (or a delayedRetryDownloadAsync, or a worker's persisted-completion re-POST) could enter the source-existence check against a path the master itself just deleted, marking the work item failed even though encoding succeeded. - Remove Failed Items button — Advanced Settings grows a button that calls a new
DELETE /api/queue/failedendpoint to drop every Failed row from the database. The next library scan re-discovers any source file still on disk; items whose source has already been replaced (the bogus "Source file was removed during encoding" backlog) simply stay gone. No video files are touched. Surfaces a clear recovery path for legitimate failures and for the bug class above. - Auto-scan directory removal needs a confirmation —
_removeDirectoryin the auto-scan panel now prompts a confirm modal before removing a watched folder, with copy that clarifies queued/completed items are unaffected and nothing on disk changes. Previously a single misclick on the trash icon silently dropped the folder.
Files Changed
Per-language audio fan-out
Snacks/Models/AudioOutputProfile.cs(new) —{Codec, Layout, BitrateKbps}row +Clone()Snacks/Models/EncoderOptions.cs— newPreserveOriginalAudio(defaultstrue) +AudioOutputslist; legacyAudioCodec/AudioBitrateKbps/TwoChannelAudioretained for backward-compat; newApplyLegacyAudioMigrationfor one-shot translationSnacks/Models/EncoderOptionsOverride.cs— newPreserveOriginalAudio?+AudioOutputs?fields;ApplyOverridesruns the legacy migration on overrides whose only audio touches are legacy fieldsSnacks/Services/FfprobeService.cs— full audio planner rewrite:_codecSpecstable,LayoutToChannels,SourceCodecQuality, per-language buckets, source-codec tie-breaking, never-zero-stream-per-language guaranteeSnacks/Services/FfprobeService.cs—AppendAudioMetanow writeslanguage=+Language (Codec Layout)titles for encoded streams; copies skip the overrideSnacks/Services/TranscodingService.cs— savings gate exemptsAudioOutputs.Count > 0so configured fan-out can grow the file (local + cluster completion paths)Snacks/Views/Shared/_AudioSettings.cshtml— new Codec & Mix card with Preserve toggle + Output formats list + add-row button + row template
Manual queue re-evaluation
Snacks/Controllers/SettingsController.cs—Saveno longer runs the library walk; newPOST /reevaluateendpoint with static_reevaluateLockreturning 409 on concurrent requests;LoadOptionshelper used by bothGetandReevaluate;MigrateLegacyAudioIfNeeded/HasNewAudioShapefor presence-aware audio migrationSnacks/Services/TranscodingService.cs— newRemoveSettingsObsoletedQueueItemsAsync(snapshot under lock → async re-eval → re-acquire lock to remove → SignalR notify +MediaFileStatus.Skippedpersist); newBackfillOriginalLanguageAsyncSnacks/Data/MediaFileRepository.cs— newReevaluateUnseenAsync(Direction B);DeleteAllFailedAsyncfor the Remove Failed Items buttonSnacks/Views/Shared/_AdvancedSettings.cshtml— new "Re-evaluate Queue" + "Remove Failed Items" buttons in the Maintenance cardSnacks/wwwroot/js/api.js—settingsApi.reevaluate()+queueApi.removeFailed()Snacks/wwwroot/js/settings/panels/advanced-panel.js— re-evaluate handler with disable-during-run + result toast; remove-failed handler with confirm modal
Original-language caching
Snacks/Models/MediaFile.cs— newOriginalLanguagecolumn (nullable string)Snacks/Data/Migrations/20260430214143_AddMediaFileOriginalLanguage.{cs,Designer.cs}(new) +SnacksDbContextModelSnapshot.cs— EF Core migration adding the columnSnacks/Data/MediaFileRepository.cs— column carried through upsertsSnacks/Services/TranscodingService.cs—ResolveOriginalLanguageAsyncis cache-first;WithOriginalLanguageMergedfor in-memory merge; bothAddFileAsyncandAnalyzeFileAsyncresolve up front + persist;FinaliseForDispatchAsyncis the new pre-dispatch gate; the legacy lookup insideConvertVideoAsyncis removedSnacks/Services/ClusterService.cs—CloneOptionsForWorkerAsyncis cache-first; persists newly resolved values; new pre-dispatch skip gate callsFinaliseForDispatchAsyncbefore claiming a slot
HDR scan column
Snacks/Models/MediaFile.cs— newIsHdrcolumnSnacks/Data/Migrations/20260430222629_AddMediaFileIsHdr.{cs,Designer.cs}(new) +SnacksDbContextModelSnapshot.csSnacks/Services/TranscodingService.cs—AddFileAsyncpersistsIsHdrfrom the live probe;AnalyzeFileAsyncreadsdbFile.IsHdrwhen no live probe is available; analyze bitrate re-measures video-only viaMeasureVideoBitrateAsyncto matchAddFileAsync
Folder override resolver
Snacks/Services/TranscodingService.cs—_folderOverrideResolverfield +SetFolderOverrideResolverinjection point;ProcessQueueAsyncapplies the override to the per-job options cloneSnacks/Services/AutoScanService.cs— wires the resolver during startup
CPU fallback scoring
Snacks/Services/ClusterService.cs—nodeHasUsableHardwarerequires the device to be present, enabled, AND list the requested codec inSupportedCodecs;autoallows CPU fallback only when no usable hardware exists for that codec
Cluster reliability
Snacks/Services/ClusterService.cs— same-source double-dispatch snapshot;FinalizeCompletionAsynccleanup ordered before source-replacing side effects; cluster pre-dispatch skip gate + cancel/stop race check
Override dialog
Snacks/Views/Shared/_AppModals.cshtml— legacy audio override toggles removed; newPreserveOriginalAudiotoggle +AudioOutputslist with row template;AudioLanguagesToKeep/SubtitleLanguagesToKeepchip-input overridesSnacks/wwwroot/js/cluster/override-dialog.js— newaudioOutputsfield type; newlanguageListfield type backed by the shared chip-input widget
Failed-item recovery + auto-scan removal confirm
Snacks/Controllers/QueueController.cs—DELETE /api/queue/failedendpointSnacks/Data/MediaFileRepository.cs—DeleteAllFailedAsyncSnacks/wwwroot/js/settings/panels/auto-scan-panel.js— confirm modal before removing a watched directory
Test suite
Snacks.Tests/(new project, ~7,000 lines) — fixtures (InMemoryDb,ProbeBuilder,AudioFlagsParser,LinuxOnlyFactAttribute); Audio (AudioPlannerTests,AudioMigrationTests,HasAudioWorkTests,LanguageMatcherTests); Subtitles (HasSubtitleWorkTests,SubtitleMappingTests); Video (CalculateBitratesTests,EncodeSkipPredicateTests,HardwareEncoderTests,RateControlAndScaleTests,VideoFilterTests); Pipeline (FullCommandScenarioTests); Settings (SettingsRoundTripTests,EncoderOptionsJsonContractTests,PresenceAwareMigrationTests,ReevaluationLockTests); Overrides (EncoderOptionOverrideTests)Snacks/Properties/AssemblyInfo.cs—InternalsVisibleTo("Snacks.Tests")Snacks.sln— test project added to the solution
Unraid template
unraid/snacks.xml(new) — Community Applications XML template (host networking for cluster discovery,/dev/dripassthrough for Intel/AMD, NVIDIA via the Nvidia-Driver plugin)unraid/README.md(new) — manual install instructions, hardware acceleration notes, path tableREADME.md— link to the Unraid template
Version bumps
Snacks/Controllers/HomeController.csSnacks/Services/ClusterDiscoveryService.cs— protocol version bump to 2.8.0Snacks/Views/Shared/_Layout.cshtmlREADME.mdbuild-and-export.batelectron-app/package.json/package-lock.json
Full documentation: README.md