github derekshreds/Snacks v2.8.0
Snacks v2.8.0

3 hours ago

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 AudioOutputProfile model — replaces the legacy single-codec / single-bitrate / TwoChannelAudio trio with a list of {Codec, Layout, BitrateKbps} rows (codec ∈ aac / ac3 / eac3 / opus; layout ∈ Source / Mono / Stereo / 5.1 / 7.1; BitrateKbps=0 means "use the codec default" — 192 for AAC/EAC3/Opus, 448 for AC3). For every language in AudioLanguagesToKeep, 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.
  • PreserveOriginalAudio toggle — pass every kept source track through with -c:a copy alongside any encoded variants. Defaults to true so out-of-the-box behavior remains "remux, don't re-encode". Combined with an empty AudioOutputs list, 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 AudioOutputs row configured. The size log line reports +Δ kept due to configured audio outputs instead 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 AppendAudioMeta is now Language (Codec Layout) instead of Language (Codec) (e.g. English (Opus 5.1) instead of English (Opus)), so a player's track picker can disambiguate two outputs of the same codec at different channel counts. Channels → label resolves to Mono / Stereo / 5.1 / 7.1, with a {N}ch fallback for unusual counts. Copy passes are unchanged — their metadata flows through with -c:a copy.
  • Encoded streams now carry full metadataAppendAudioMeta writes language=<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 say 5.1 Surround after a stereo downmix). Copies are intentionally left alone — -c:a copy preserves the source's language + title metadata, and overriding would clobber descriptive titles like Surround 5.1 on the original tracks.
  • Lossless-source priority for re-encode picks — when multiple source tracks could satisfy a re-encode profile, SourceCodecQuality ranks lossless formats above lossy (flac/truehd/mlp/alac/pcm rank highest, then dts/dtshd, then eac3/ac3, then aac/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 migrationEncoderOptions.ApplyLegacyAudioMigration translates an on-disk AudioCodec + AudioBitrateKbps + TwoChannelAudio config into the new PreserveOriginalAudio + AudioOutputs shape (copyPreserve=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 either PreserveOriginalAudio or AudioOutputs, the user's settings are authoritative and migration never runs again — even if they save with an empty AudioOutputs list. 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 walkPOST /api/settings is 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/reevaluate endpoint + 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 / SubtitleStreams both 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 cleanupRemoveSettingsObsoletedQueueItemsAsync walks the in-memory work queue and drops items whose cached MediaFile row 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 to MediaFileStatus.Skipped.
  • Original-language pre-pass before re-evaluating — the re-evaluate endpoint backfills missing OriginalLanguage values across Skipped + Unseen + Queued rows before running the predicates, so WouldSkipUnderOptions sees the same merged keep lists ConvertVideoAsync would 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) in SettingsController rejects 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 OriginalLanguage column on MediaFile — ISO 639-1 (2-letter) code resolved via the configured OriginalLanguageProvider (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 that ConvertVideoAsync would build at encode time, without re-querying the integration provider on every settings save / re-eval / dispatch. New EF Core migration 20260430214143_AddMediaFileOriginalLanguage adds the column.
  • Resolved up front in every skip ladderResolveOriginalLanguageAsync is now called eagerly in AddFileAsync, AnalyzeFileAsync, the local dispatcher's pre-encode FinaliseForDispatchAsync, and the cluster master's CloneOptionsForWorkerAsync. All four sites first check the cached MediaFile.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 by KeepOriginalLanguage being resolved late in ConvertVideoAsync.
  • The KeepOriginalLanguage lookup moved out of ConvertVideoAsync — the per-encode lookup that used to live in ConvertVideoAsync is gone; options arrive 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-runs WouldSkipUnderOptions) 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 of AddFileAsync'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 IsHdr column on MediaFile — populated by FfprobeService.IsHdr (PQ / HLG / Dolby Vision detection) at scan time and cached on the row. New EF Core migration 20260430222629_AddMediaFileIsHdr adds the column.
  • WouldEncodeBeNoOp reads cached HDR — the no-op skip gate in AnalyzeFileAsync previously hard-coded isHdr=false when only the cached row was available (no live probe). On a tonemap-on user, this caused analyze to predict Skip while AddFileAsync would queue for encode (the active filter forces re-encode). The gate now reads dbFile.IsHdr when 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 by AutoScanService lets TranscodingService.ProcessQueueAsync apply 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 AddFileAsync does — the analyze dry-run's bitrate calculation used to be a flat file_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 that AddFileAsync uses. Without this, a file with chunky audio would predict Queue (over target by total bitrate) while AddFileAsync would 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 / TwoChannelAudio toggles are gone from the override dialog. Replaced with PreserveOriginalAudio (bool) and AudioOutputs (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, and EncoderOptionsOverride.ApplyOverrides migrates them in place if a saved override only touched the old fields.
  • Per-language keep-list overridesAudioLanguagesToKeep and SubtitleLanguagesToKeep are 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's SupportedCodecs. 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 through FinalizeCompletionAsync, 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 effectsFinalizeCompletionAsync now removes the job from _remoteJobs before HandleRemoteCompletion runs. Without this, a heartbeat-driven re-fire of the completion handler (or a delayed RetryDownloadAsync, 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/failed endpoint 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_removeDirectory in 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 — new PreserveOriginalAudio (defaults true) + AudioOutputs list; legacy AudioCodec / AudioBitrateKbps / TwoChannelAudio retained for backward-compat; new ApplyLegacyAudioMigration for one-shot translation
  • Snacks/Models/EncoderOptionsOverride.cs — new PreserveOriginalAudio? + AudioOutputs? fields; ApplyOverrides runs the legacy migration on overrides whose only audio touches are legacy fields
  • Snacks/Services/FfprobeService.cs — full audio planner rewrite: _codecSpecs table, LayoutToChannels, SourceCodecQuality, per-language buckets, source-codec tie-breaking, never-zero-stream-per-language guarantee
  • Snacks/Services/FfprobeService.csAppendAudioMeta now writes language= + Language (Codec Layout) titles for encoded streams; copies skip the override
  • Snacks/Services/TranscodingService.cs — savings gate exempts AudioOutputs.Count > 0 so 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.csSave no longer runs the library walk; new POST /reevaluate endpoint with static _reevaluateLock returning 409 on concurrent requests; LoadOptions helper used by both Get and Reevaluate; MigrateLegacyAudioIfNeeded / HasNewAudioShape for presence-aware audio migration
  • Snacks/Services/TranscodingService.cs — new RemoveSettingsObsoletedQueueItemsAsync (snapshot under lock → async re-eval → re-acquire lock to remove → SignalR notify + MediaFileStatus.Skipped persist); new BackfillOriginalLanguageAsync
  • Snacks/Data/MediaFileRepository.cs — new ReevaluateUnseenAsync (Direction B); DeleteAllFailedAsync for the Remove Failed Items button
  • Snacks/Views/Shared/_AdvancedSettings.cshtml — new "Re-evaluate Queue" + "Remove Failed Items" buttons in the Maintenance card
  • Snacks/wwwroot/js/api.jssettingsApi.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 — new OriginalLanguage column (nullable string)
  • Snacks/Data/Migrations/20260430214143_AddMediaFileOriginalLanguage.{cs,Designer.cs} (new) + SnacksDbContextModelSnapshot.cs — EF Core migration adding the column
  • Snacks/Data/MediaFileRepository.cs — column carried through upserts
  • Snacks/Services/TranscodingService.csResolveOriginalLanguageAsync is cache-first; WithOriginalLanguageMerged for in-memory merge; both AddFileAsync and AnalyzeFileAsync resolve up front + persist; FinaliseForDispatchAsync is the new pre-dispatch gate; the legacy lookup inside ConvertVideoAsync is removed
  • Snacks/Services/ClusterService.csCloneOptionsForWorkerAsync is cache-first; persists newly resolved values; new pre-dispatch skip gate calls FinaliseForDispatchAsync before claiming a slot

HDR scan column

  • Snacks/Models/MediaFile.cs — new IsHdr column
  • Snacks/Data/Migrations/20260430222629_AddMediaFileIsHdr.{cs,Designer.cs} (new) + SnacksDbContextModelSnapshot.cs
  • Snacks/Services/TranscodingService.csAddFileAsync persists IsHdr from the live probe; AnalyzeFileAsync reads dbFile.IsHdr when no live probe is available; analyze bitrate re-measures video-only via MeasureVideoBitrateAsync to match AddFileAsync

Folder override resolver

  • Snacks/Services/TranscodingService.cs_folderOverrideResolver field + SetFolderOverrideResolver injection point; ProcessQueueAsync applies the override to the per-job options clone
  • Snacks/Services/AutoScanService.cs — wires the resolver during startup

CPU fallback scoring

  • Snacks/Services/ClusterService.csnodeHasUsableHardware requires the device to be present, enabled, AND list the requested codec in SupportedCodecs; auto allows CPU fallback only when no usable hardware exists for that codec

Cluster reliability

  • Snacks/Services/ClusterService.cs — same-source double-dispatch snapshot; FinalizeCompletionAsync cleanup 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; new PreserveOriginalAudio toggle + AudioOutputs list with row template; AudioLanguagesToKeep / SubtitleLanguagesToKeep chip-input overrides
  • Snacks/wwwroot/js/cluster/override-dialog.js — new audioOutputs field type; new languageList field type backed by the shared chip-input widget

Failed-item recovery + auto-scan removal confirm

  • Snacks/Controllers/QueueController.csDELETE /api/queue/failed endpoint
  • Snacks/Data/MediaFileRepository.csDeleteAllFailedAsync
  • Snacks/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.csInternalsVisibleTo("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/dri passthrough for Intel/AMD, NVIDIA via the Nvidia-Driver plugin)
  • unraid/README.md (new) — manual install instructions, hardware acceleration notes, path table
  • README.md — link to the Unraid template

Version bumps

  • Snacks/Controllers/HomeController.cs
  • Snacks/Services/ClusterDiscoveryService.cs — protocol version bump to 2.8.0
  • Snacks/Views/Shared/_Layout.cshtml
  • README.md
  • build-and-export.bat
  • 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.