Snacks v2.14.0
Automated Media Library Transcoder
A major release built around a performance overhaul that lets Snacks run libraries with hundreds of thousands of files on modest NAS hardware without being OOM-killed. The pending queue used to live entirely in memory — a single 30k–500k file sweep would pin every work item (and its probe result) forever, climbing past 6 GB. The queue is now the database; memory holds only a bounded working window, active jobs, and recent results. Scans are chunked, checkpointed, resumable, and parallelized. On top of that foundation this release adds queue priority (move-to-front, newest-first ordering, per-folder priority), manual remux (the "Process Item"/"Process Directory" actions now remux already-at-target files without enabling Hybrid mode globally), a Library Health page with rolling background verification and one-click cleanup of broken files, a Prometheus /metrics endpoint, quality presets, background library analysis (no more analyze timeouts), safer settings handling, a slate of cluster reliability fixes, and a new end-to-end test suite that guards the memory regression at scale. It also ships two important encoder-correctness fixes — Snacks no longer drops all audio when no track matches the language keep-list, and no longer re-encodes a more-efficient source (AV1) down into a less-efficient target (HEVC) when it's already within budget.
Performance overhaul: a database-first queue
The pending queue is now the database, not memory
The full pending queue used to be held in memory (_workItems plus a bitrate-sorted _workQueue). A large first sweep pinned every item and its ProbeResult indefinitely, reaching 6+ GB and getting OOM-killed on NAS hardware. The Queued rows in SQLite are now the source of truth; memory keeps only what it needs to make progress.
TranscodingService— bounded working window (QueueWindowSize = 50) — memory holds active jobs, a hydrated top-N window, and recent terminal items only.SyncQueueWindowAsyncreconciles the window against the DB top-N, gated by a_queueWindowDirtyflag and_windowSyncLockso a tick with nothing to do is free. It evicts items that fell out of top-N (their rows stayQueued), lazily hydrates new ones, and quarantines vanished sources toUnseen.- Storage-outage guard — if the window holds ≥5 rows and every source is unreachable, the sync bails untouched rather than shredding the queue and its priorities during a transient NAS/share outage.
- Kind floor (
KindFloor = 8) — reserves window slots for music/video that the bitrate-weighted order would otherwise starve. - Window rotation (
_windowRotationOffset) — when the whole window is locally unservable (e.g. 50 consecutive 4K items under a master-excludes-4K policy) but the DB holds more, the window advances byQueueWindowSizeper pass so deeper servable rows rotate in, and stops churning once a full backlog walk yields nothing servable. _pathIndex(ConcurrentDictionary<normalizedPath, id>) — replaces O(n²) duplicate detection; all writes funnel throughRegisterWorkItem/UnregisterWorkItem/ClearWorkItems/FindWorkItemByPath.- Terminal-item memory cap (
TerminalWorkItemCap = 1000) — a new 5-minute_workItemSweepTimerrunsSweepTerminalWorkItems, releasingProbeon items quiet for >2 minutes, then evicting the oldest terminal items beyond the cap (with a re-check so a requeuedStoppeditem isn't lost).RequeueWorkItemnow re-registers unconditionally to heal sweep-vs-revive races. KickQueueAsyncreplacesRestoreToQueueAsync— startup no longer replays one restore per queued row (a 500k-row queue used to block every encode at boot). It marks the window dirty and wakes the scheduler and cluster dispatcher once.NotifyQueueChangedAsync— throttledQueueChangedSignalR broadcast (1/sec with a guaranteed trailing send). The UI refetches a DB-sourced page rather than receiving per-item payloads, so a multi-hour sweep no longer floods the client with events.
Canonical, single ordering everywhere
CompareQueueOrder(a, b[, newestFirst])— one comparator defines queue order: Priority desc → (newestFirst ? QueuedAt desc : Bitrate desc). It backs every_workQueue.Sort, the API listing, and the DB query, so on-screen order matches dispatch order exactly.WorkItemmodel — newint PriorityandDateTime QueuedAt(a newest-first tiebreaker distinct from object-buildCreatedAt).Pathis backed by_pathwith a cached[JsonIgnore] NormalizedPathto kill repeatedGetFullPathcalls, andProbeis now[JsonIgnore], populated lazily and released at terminal state.
Reliability fixes folded into the rewrite
RecordWatchdogFailureAsync— a per-job watchdog cancel (no progress for 15 minutes) now records aFailedterminal status. Previously a stalled standalone encode could sit inProcessingforever.PruneMissingWorkItemsAsync/DropMissingWorkItemAsync— drop work items whose source vanished (removing the memory entry, the DB row, and pushingWorkItemRemoved), skipping active encodes. The dispatch loop also drops missing sources at dispatch time.EncodedBitrateKbpsbug fix — now divides by 1000 (decimal kbit) instead of 1024, matching every other bitrate in the app.
Scan side: chunked, checkpointed, resumable, parallel
AutoScanServicechunked sweep — each watched tree is walked inDirChunkSize = 200-directory chunks (Ordinal-sorted). Per chunk: enumerate → two batched DB lookups → filter → process. Memory stays bounded regardless of library size.- Resumable checkpoint — a
ScanCheckpoint(per-root completed-subdir count) is persisted toscan-checkpoint.jsonafter each chunk (Save/Load/ClearScanCheckpoint; treated as null if missing, corrupt, or >24h stale), so an interrupted first sweep of a 500k-file library resumes mid-tree instead of starting over. - Parallel per-file processing —
ProcessDiscoveredFileAsyncruns underParallel.ForEachAsyncwithMaxDegreeOfParallelism = Clamp(ProcessorCount / 2, 2, 8)(the old sequential first sweep "took days"). Companion-completed marks are collected in aConcurrentBagand flushed as one batchedSetStatusBatchAsyncper chunk. Progress is reported per-chunk via aScanProgressSignalR event, not per-file. - DB-first startup resume —
ResumeLocalQueueItemsAsyncnow just requeues orphaned localProcessingrows, counts queued local rows, marks the window dirty, and kicks the queue — no per-row replay. - Concurrency / robustness —
_config.Directoriesis snapshotted under_configLock(fixes "Collection was modified");ClearHistoryguards_scanCts.Cancel()againstObjectDisposedExceptionand drops the checkpoint; the original is probed once (not per companion); a video with duration ≤0 is treated as unreadable so a valid[snacks]encode is never deleted on a flaky probe;PruneDeletedFilesAsync(DB) is mirrored to the queue viaPruneMissingWorkItemsAsync. HomeController.Index— no longer materializes the queue into the page model (hundreds of MB per page load on big sweeps); the frontend pages/api/queue/items.
Tests
Snacks.Tests/Pipeline/DbQueueTests.cs(new) — window orders by priority→bitrate, excludes remote-assigned and non-Queuedrows, supports newest-first, pages slice + total, refuses to bump a non-Queuedrow, guards status flips toQueued-only, requeues only orphaned localProcessingrows on restart, resets all queued toUnseenand clears priority, hydrates/quarantines idempotently, honors the kind filter, and parsesmf-ids.Snacks.Tests/Pipeline/QueueOrderTests.cs(new) — default bitrate-desc ordering, a prioritized item beats a higher-bitrate one, and later prioritization outranks earlier.
Queue priority
Move to front, newest-first, and per-folder priority
POST /api/queue/prioritize/{id}—QueueController→TranscodingService.PrioritizeWorkItemAsync. Accepts anmf-{rowId}or a GUID; the authoritative write isMediaFileRepository.BumpPriorityToFrontAsync(Priority = max + 1), and the hydrated in-memory copy is updated in place. A new "Move to front" button (fa-angles-up,data-action="prioritize") is rendered per row.- Newest-first option —
EncoderOptions.QueueNewestFirst(defaultfalse) flips the in-priority tiebreaker from bitrate to recency, surfaced as "Newest Files First" in general settings and wired throughSetQueueOrderNewestFirst. - Per-folder priority —
EncoderOptionsOverride.QueuePriority(int?) sets a base priority for a watched folder; exposed via anovr_QueuePriorityoverride control. - DB-sourced listing —
GET /api/queue/itemsis now async: pending rows come from the DB (GetQueuedPageAsync/CountQueuedLocalAsync), terminal items from memory, and active paths are filtered out of pending. Rows project asid = "mf-{Id}"viaToPendingDto.GET /api/queue/statsruns throughGetWorkItemCountsAsync(pending from one indexed COUNT). queue-manager.js— additive reconciliation of_workItems(no clear-then-refill), throttled refresh distinguishing full vs queue-only refetches, newQueueChanged/ScanProgresshandlers, and a first-run onboarding empty state ("Welcome to Snacks").
DB columns & repository
- Migration
20260610021037_AddQueuePriorityAndVerification— addsPriority(INTEGER NOT NULL default 0) andLastVerifiedAt(TEXT nullable), plus indexesIX_MediaFiles_Status_Priority_Bitrate(Status asc, Priority desc, Bitrate desc) andIX_MediaFiles_LastVerifiedAt. - Migration
20260610024354_AddVerifyResult— addsLastVerifyResult(TEXT, max 2048, nullable). Both mirrored inSnacksDbContextandModels/MediaFile. MediaFileRepository(new) —IDbContextFactorycontext-per-op with WAL and aSaveChangesWithRetryAsync(SQLITE_BUSY backoff). Queue methods:GetQueueWindowAsync(take, newestFirst, skip, kind),GetQueuedPageAsync,CountQueuedLocalAsync,BumpPriorityToFrontAsync,SetQueuedRowStatusAsync(guarded),ReevaluateQueuedAsync,SetStatusBatchAsync(chunked IN-list),RequeueOrphanedLocalProcessingAsync,ResetAllQueuedAsync, plus the verification and health queries below.UpsertAsynconly copies a non-zeroPriority, so a rescan never erases a move-to-front.RemoveByPathAsyncremoves a single row by normalized path at the dispatch boundary.
Manual queue actions remux already-at-target files
"Process Item" / "Process Directory" now run as Hybrid mux
The manual queue actions used to honor the global encoding mode, so with the default Transcode mode a file already at the bitrate target was skipped — there was no way to remux a handful of specific files (re-apply audio/subtitle settings, normalize the container) without flipping the whole library to Hybrid mode. Both actions now run as a Hybrid mux pass for the files they touch, regardless of the global mode: an at-target file gets a video-copy remux (audio/subs re-applied, container normalized to the configured Format), while above-target or wrong-codec files still re-encode. A file with genuinely nothing to do (target codec, at target, matching streams, matching container, no filters) is still skipped — the actions process anything that needs work, and only that. Works for both local and cluster-dispatched encodes.
MediaFile.ForceMux(new column) + migrationAddForceMux— per-file flag marking a row queued by an explicit user action. Persisted so the intent survives the work item being evicted from the in-memory working window or a process restart. Sticky-true across rescans inUpsertAsync; cleared on terminal completion (SetStatusAndLastEncodedAtAsync) and on file reset (ResetFileAsync,ResetAllQueuedAsync). Hydrated onto the rebuiltWorkIteminSyncQueueWindowAsync.WorkItem.ForceMux(new) — in-memory companion carried from queue-time and DB hydration through to dispatch and the encoder.TranscodingService.NeedsContainerChange(new) — true when the source extension differs from the configured outputFormat(treatingmp4/m4vas the same container). Folded into the force-mux skip ladder, the dispatch skip gate (WouldSkipUnderOptions), and the mux-pass decision (isMuxPass/IsMuxPass) so a container-only change is honored as work and produces a video-copy remux even for AV1/H.264 targets (HEVC already copied at target).AddFileAsync/AddDirectoryAsync— newforceMuxparameter. A force-mux file is evaluated as Hybrid (the global Transcode mode is upgraded on a per-job clone), so the bitrate/codec/no-op skip gates are bypassed when there's muxable or container work.AddDirectoryAsyncalso now passesforce: true, matching the long-standingProcessFilebehavior so the action reprocesses already-completed files instead of silently skipping them.- Dispatch — both the local (
ProcessQueueAsync) and cluster (DispatchLoopAsync) dispatchers upgrade a force-mux item'sEncodingModeTranscode→Hybrid before the pre-dispatch skip gate. On the cluster path the upgraded options are cloned intoJobMetadata.Options, so the worker mux-passes correctly with no protocol change. LibraryController.ProcessFile/ProcessDirectory— passforce: true, forceMux: true.
Note: the Analyze (Dry Run) preview still evaluates under the global options, so it will show these at-target files as "Skip" even though the manual action remuxes them.
Encoder correctness fixes
Never produce a file with no audio
A language keep-list (default ["en"]) filtered source audio before anything else, and if no track matched — an untagged track, an und tag, or a foreign-only file — the planner returned an empty audio map and ffmpeg wrote an audio-less output, silently. PreserveOriginalAudio didn't help because the language filter ran first. With Delete Original enabled, the smaller no-audio output even counted as "savings" and replaced the source.
FfprobeService.MapAudiowhole-file safeguard — when language/commentary filtering matches nothing but the source has audio, the planner now keeps the source tracks instead of emitting nothing (preferring real tracks; falling back to commentary-only when that's all there is), routing them through the copy path so container-copy compatibility still holds (e.g. a foreign TrueHD track into MP4 falls back to AAC). It logs a warning so the fallback is visible, and a genuinely audio-less source still stays audio-less. Covered by newSnacks.Tests/Audio/AudioPlannerTestscases (foreign-only, untagged,und, commentary-only, preserve-off, container fallback, and the "a language did match → foreign track still dropped" boundary).
Don't downgrade an already-efficient codec
The "already at target codec" check used an exact match, so an AV1 source against an HEVC target read as "not the target codec" and fell into the H.264 shrink path — a 97 kbps AV1 was re-encoded down to a smaller HEVC, losing quality for no real gain.
- Codec-efficiency hierarchy (
SourceCodecMeetsTarget/CodecRank) — AV1 (3) > HEVC (2) > H.264 (1) > legacy (0). A source counts as "already at target" when it's at least as efficient as the target encoder, so an at-budget AV1/HEVC source is skipped instead of re-encoded, while H.264 and legacy codecs still convert (and an H.264 target still correctly rejects MPEG-2/VC-1/VP9). Applied consistently across the scan-phase ladder (AddFileAsync), the analyze preview (AnalyzeFileAsync), the dispatch re-check (WouldSkipUnderOptions), and mux-pass eligibility (MeetsBitrateTarget); skip reasons now name the source codec ("Already AV1 · 97 kbps ≤ …"). NewEncodeSkipPredicateTestspin the full hierarchy matrix and the AV1-under-HEVC skip. (Note: an AV1 source over budget is still re-encoded to the configured HEVC target — only within-budget files are left alone.)
Library Health & rolling verification
A new Health page that flags broken encodes before you notice them
LibraryHealthController(new) — serves theGET /library-healthview; a "Health" link is added to the nav in_Layout.cshtml.FileHealthService(new, singleton) —VerifyAsync(path)runs ffprobe sanity checks (no streams, missing audio/video, zero duration) plus a bounded ffmpeg decode of 8-second samples at the start, middle, and near-end of the file. It limits concurrency withSemaphoreSlim(2), enforces a 90-second per-sample watchdog that kills the process tree, builds commands withArgumentList(no option injection), and returns a deduped, truncatedVerifyResult(bool Ok, IReadOnlyList<string> Issues).RollingVerificationService(new, hosted service) — a background timer (first tick at +10 min, then hourly), single-flight, skipped on cluster workers. It readsVerifyFilesPerDayfrom settings.json (0 disables; default 0), verifiesmax(1, perDay / 24)files per hour oldest-verified-first, and stores"ok"or the truncated issue list. Unreachable files are marked"missing", and 5 consecutive missing files abort the tick (mount offline).- Endpoints on
LibraryController—GET /api/library/health(filtersno-audio|no-video|no-duration|failed|verify-failed, plusq,skip,limit ≤ 500; the summary uses whole-library SQL counts),GET /api/library/insights, andPOST /api/library/health/verifyfor an on-demand check. js/health/library-health.js(new) — a single-page view with six summary/filter cards, debounced search, server-side paging (100/page) with a stale-response guard, a per-row Verify button, and library-overview proportion bars.- Setting —
EncoderOptions.VerifyFilesPerDay(default 0), surfaced in_AdvancedSettings.cshtmlas "Rolling Verification" (settingsVerifyFilesPerDay, 0–10000 step 10).
One-click cleanup of broken files
- Per-row delete + "Delete All" — each flagged row gets a trash button, and the header gets a "Delete All (N)" button that acts on the entire active filter across all pages (not just the visible 100). Both go through a confirmation modal.
POST /api/library/health/delete(single) andPOST /api/library/health/delete-all(bulk byfilter+q, capped at 5000/request and reporting deleted/failed/capped). - Disk delete + verified DB removal —
TryDeleteFlaggedAsyncrefuses any path outside the allowed library root, deletes the file via the retry-awareFileService.FileDeleteAsync, and only removes theMediaFilerow once the file is confirmed gone — so a re-downloaded file is rediscovered as a freshUnseenentry on the next scan, and a failed delete (locked/permissions) leaves the row and its health flag intact.EncodeHistoryrows are left untouched so dashboard stats stay accurate. NewMediaFileRepository.GetHealthPathsAsyncreturns the full matching path set for the bulk op.
Tests
Snacks.Tests/Cluster/...and repository queries back the health/verification queries (GetVerificationCandidatesAsyncoldest-first,SetVerifyResultAsync,GetVerificationStatsAsync,GetHealthSummaryAsync,GetHealthPageAsync,GetLibraryInsightsAsync).
Prometheus metrics
GET /metrics
MetricsController(new) — emits Prometheus 0.0.4 text format with a 15-second cache behind a double-checked render lock. It is auth-exempt via an exact-match allowlist inAuthMiddleware(exact, not prefix). Metrics cover queue gauges, lifetime counters (encodes, bytes saved, encode-seconds, content-seconds), per-node 30-day bytes-saved (label-escaped), health gauges, library/verify gauges, and scan gauges includingsnacks_scan_last_completed_timestamp_seconds. Everything is an aggregate — no paths or PII are exposed.Models/LibraryInsights(new) —TotalFiles/TotalBytes/HdrFiles/MusicFilesplusCodecs/Resolutions/Statuseslists of nestedSlice(Label, Count, Bytes). Resolution buckets are derived by width (4K > 1920, 1080p, 720p, SD).
Background library analysis
"Analyze (Dry Run)" no longer times out on large libraries
LibraryAnalysisJobService(new, singleton) — whole-library analysis ran inside a single HTTP request and timed out. It now runs as a background job (Start(dir, opts, recursive)— one at a time, otherwise 409;Get;Cancel). Results are capped atFullResultCap = 20_000(beyond which the job isTruncated, keeps 1000 preview rows, and reports authoritative per-decision totals inSummary), with 30-minute retention.LibraryControlleranalyze endpoints reworked —POST /api/library/analyze-directoryis fire-and-forget returning{success, jobId}(409 if one is running); newGET analyze-status/{id},GET analyze-results/{id}(409 while running), andPOST analyze-cancel/{id}.GET /api/library/filesis now paged (skip/limit ≤ 5000) returning{files, total, videoTotal, musicTotal, truncated}.- JS —
analyze-modal.jsrewritten to start → poll (750ms) → fetch results with a progress bar and truncation notice;library-browser.jsswitched to server-side paging with "Load more."
Quality presets
Built-in and user-defined preset snapshots
js/settings/presets.js(new) — four built-in presets (Space Saver, Balanced [recommended], Quality First, Max Compatibility), each setting only Format / Codec / TargetBitrate / 4K-multiplier / preset and leaving everything else untouched. User presets are named full-form snapshots stored inconfig/presets.json, with export/import as.snacks-preset.json.SettingsControllerendpoints —GET/POSTpresets,DELETE presets/{name},GET presets/export/{name}(with asnacksPreset=1marker), andPOST presets/import. Backed bySavedPreset/PresetRequest,MaxPresets = 50, case-insensitive overwrite, and an atomic write-then-rename under_presetsLock.api.jsgainspresetsApi;_GeneralSettings.cshtmlgains a "Quality Presets" panel.- Apply confirmation — applying a built-in or user preset now prompts first ("Apply the "X" preset? This overwrites your current encoder settings.") so a stray card click can't silently wipe a hand-tuned config.
Safer settings handling
Settings are no longer silently dropped or wiped
- Deep-merge on save —
SettingsController.SaveSettingsnow merges the form payload over the existingsettings.json(MergeWithExistingSettings/DeepMergeJson) instead of overwriting it, so fields with no UI control (e.g.Music.VbrQuality) survive the first auto-save. - Auto-save armed only after a successful restore —
encoder-form.jsarms auto-save per prefix only after that prefix restores (restoredPrefixes). Before restore the form still holds HTML defaults; saving those used to wipe real settings (the "opened during a server blip and everything reset" bug). A newreportAutoSaveStatus/#settingsAutoSaveStatusindicator surfaces save state. - Strictly-sparse form application — the new
applyEncoderOptionsToFormleaves inputs untouched for absent keys, so applying a preset doesn't wipe unrelated fields;sel()treats""as missing andnum()clamps to the input's min/max. ALEGACY_SELECT_VALUESmigration maps old values forward (DownscalePolicy IfLarger → CapAtTarget;HardwareAcceleration nvenc/cuda → nvidia,vaapi/qsv → intel,amf → amd). main.js— debounced encoder auto-save (600ms) with aNON_ENCODER_ID_PREFIXESexclusion list, an explicit confirmation on the "Replace Original Files" toggle, and derived-UI handlers (syncCodecForFormatdisables codec when Format = webm;syncMuxOnlyNotice).- Tab consolidation —
_AppModals.cshtmlcollapses the settings tabs from 13 → 8 (Video = Mux + Processing; Audio & Subtitles; Connections, renamed from Integrations; Cluster = Cluster + Transfers + Schedule; System = Security + Advanced), keeping element IDs unchanged. - New / fixed settings —
EncoderOptionsgainsEncodingLogRetentionDays(7),VerifyFilesPerDay(0), andQueueNewestFirst(false), all included inClone.EncoderOptionsOverride.AudioLanguagesToKeep/SubtitleLanguagesToKeepare now copied into new lists rather than aliased, so dispatch-time merges no longer mutate saved config. - Panel fixes —
scheduling-panel.jswarns when a schedule window has no days selected;networking-panel.jsclamps chunk size to its min/max.
Cluster
Multi-share path rewrites, hardened discovery, and reliability fixes
ClusterConfig— newSharedStorageRewriteFrom/SharedStorageRewriteToand aList<SharedStoragePathRewrites>replacing the single legacy pair (kept for back-compat).EffectiveRewrites()merges and orders rewrites longest-From-first, fixing setups where a node with multiple shares was forced into upload mode for every share but one.ClusterDiscoveryService— UDP discovery now carries a rotating HMAC token (ComputeDiscoveryToken, 10-minute bucket, compared withFixedTimeEquals) instead of a brute-forceable SHA-256 of the secret, falling back to the legacysecretHashfor mixed-version clusters.PerformHandshakeAsyncreturnsbooland only reports success on a real handshake, so the register loop no longer falsely flipsregisteredwhen the master is unreachable.ClusterService— a non-reentrant heartbeat (Interlockedguard) with a 10-second per-probe timeout; discovery now always starts (it was gated in a way that silently disabled manually-added nodes when auto-discovery was off);node.ActiveJobsmutations are wrapped inlock(node);_slotLedger.Clear()on recovery; a source-vanished guard before dispatch;EnsureQueueWindowAsync()before dequeue; and an upload-resume re-key of the slot ledger, options, and UI chip.SlotLedger— newRekey(old, new)for the atomic reservation move on upload-resume.ClusterController— multi-worker-per-host disambiguation (prefer the node matchingGetRemoteJobAssignedNodeId);ReceiveFile's size limit raised toTransferLimits.MaxChunkRequestBytes, fixing 413s on large chunks.ClusterAdminControllerexposesSharedStoragePathRewrites.- Transfer plumbing — new
TransferLimitsstatic (Min 4MB / Max 256MB / +8MB header headroom) is the single source for chunk bounds (also used byNetworkingSettingsService);ClusterFileTransferServicereuses a single chunk buffer with an EOF guard for a source that shrank mid-upload;ClusterNodeJobServicefixes a receive-lock semaphore/CTS leak and a double slot-release. - JS / view —
_ClusterSettings.cshtmluses a singlefrom => totextarea;cluster-settings-form.jsparses and validates it (rejecting malformed lines loudly and clearing the legacy fields);cluster-dashboard.jsskips background fetches when the panel is unmounted.
Tests
Snacks.Tests/Cluster/DiscoveryTokenTests.cs,SharedStorageRewriteTests.cs, andSlotLedgerTests.cs(all new) — token bucket/equality, longest-From-first rewrite ordering, and slot semantics including a 32-way concurrent reserve picking a single winner plusRekeybehavior.
Media analysis, subtitle & notification fixes
MediaTypeDetector.ExtractMovieTitle— now finds the year before stripping brackets soTitle (2010)keeps its year, and uses the lastYearPatternmatch at index > 0 to handle2001 A Space Odyssey (1968)and1917.mkv. Covered by the newMediaTypeDetectorTests.FfprobeService— three FFmpeg/ffprobe calls switched toArgumentList(filename arg-injection hardening) and gained a 5-minute watchdog that throwsTimeoutExceptionon a hung probe, instead of holding the scan lock forever (a hang could yield an emptyProbeResultand cause valid output to be deleted).PgsParser— PGS→SRT fixes: a palette-update-only PCS no longer emits a duplicate cue, back-to-back replacement cues are no longer dropped, and epoch-clear ordering is corrected.NativeOcrService/SubtitleExtractionService—ArgumentListhardening, and a failed extraction now deletes its partial sidecar instead of leaving a truncated.srt/.assfor Plex/Jellyfin.NotificationService/NotificationsController— newSendTestAsync(dest); anapprise://→http://andapprises://→https://rewrite so apprise destinations actually deliver; the test endpoint no longer swaps live config mid-test (which could wipe config on a crash) and reuses the stored secret when the UI sends an empty value.
Security & hardening
utils/dom.jsescapeHtmlnow escapes"and', closing an attribute-context XSS via crafted filenames.- Argument-injection hardening — ffprobe, OCR, subtitle extraction, and the health verifier all build commands with
ArgumentList. /metricsauth — exact-match allowlist inAuthMiddlewareso the metrics path is exempt without opening a prefix.- HMAC cluster discovery — see the Cluster section above.
download.jsadds a double-click re-entry guard;dashboard.jsremoves a leaked#dashTooltipon teardown.
End-to-end test suite
e2e/(new, bash; Linux/macOS) —lib.sh(per-instance isolation),generate-library.sh(lavfi clips plus hardlink fan-out to build ~100k files in about a minute on a few hundred MB), andwatch-memory.sh, with four scenarios:- 01 — sweep memory: first-sweep peak RSS ≤ 700 MB and queue integrity at scale (the headline performance-regression guard).
- 02 — cluster dispatch: a 3-instance cluster where ≥2 nodes actually encode.
- 03 — restart/resume: a clean restart preserves the pending queue exactly; a crash leaves zero stranded
Processingrows. - 04 — priority: move-to-front changes the real dispatch order.
.gitignoreignorese2e/.buildande2e/.runs.
Dashboard & UI fixes
- Stable per-node throughput —
ClusterConfig.NodeIddefaulted to a fresh GUID and a standalone install never wrotecluster.json, so every launch minted a new node identity and the dashboard showed the same machine as a brand-new node after each restart (one file per "node").ClusterService.LoadConfignow persists the NodeId on first run, and per-node throughput groups by hostname (EncodeHistoryRepository.GetNodeThroughputAsync) so the stable machine name is the identity — existing duplicate rows coalesce immediately. - "No Savings" badge — the
NoSavingsstatus rendered as a run-on, uncolored "NOSAVINGS". AgetStatusLabelhelper spaces compound status names ("No Savings"), a.status-nosavingscolor rule was added, and the Library Overview "Processing Status" labels are humanized the same way. - Dashboard table alignment —
.dash-filesetdisplay:flexdirectly on a<td>, which pulled the cell out of the table's column model and drifted the sticky header out of alignment (Recent Encodes + Top Compression Wins). The flex now lives on an inner.dash-file-innerwrapper, so headers line up with the body. - Consistent table styling — the Library Health and Analyze (dry-run) tables now use the dashboard's
.dash-tablelook (sticky elevated header, file-icon chip, padding, hover) instead of plain Bootstrap tables. - First-run onboarding — the "Welcome to Snacks" hero showed whenever the queue was empty, including after a restart of an established library. It's now gated on a
knownFilescount (every row the DB has ever recorded, surfaced in/api/queue/stats), so it only appears on a genuine first run.
Other changes
Program.cs— registersLibraryAnalysisJobServiceandFileHealthService(singletons) andRollingVerificationService(hosted) alongside the existingLogRetentionService.FileService— working-directory fallback from/app/worktoLocalApplicationData/Snacks/workfor bare-metal writability.IntegrationService— clears the *arr language cache and TVDB token when settings change, fixing a stale-key problem.build-and-export.bat— adds the "Type YES to continue" guard before tagging and pushing.
Files Changed
Performance overhaul (DB-first queue & scans)
Snacks/Services/TranscodingService.cs— DB-first queue, working window, path index, terminal sweep, canonical ordering, prioritize, watchdog-failure, missing-source dropSnacks/Services/AutoScanService.cs— chunked/checkpointed/parallel resumable sweep, DB-first resumeSnacks/Models/WorkItem.cs—Priority,QueuedAt, cachedNormalizedPath,[JsonIgnore] ProbeSnacks/Controllers/HomeController.cs— stop materializing the queue into the page model; version bumpSnacks.Tests/Pipeline/DbQueueTests.cs,Snacks.Tests/Pipeline/QueueOrderTests.cs— newSnacks.Tests/Pipeline/FullCommandScenarioTests.cs— updated
Queue priority & DB schema
Snacks/Controllers/QueueController.cs—prioritizeendpoint, DB-sourced listing/statsSnacks/Data/MediaFileRepository.cs— new repository (queue, verification, health queries)Snacks/Data/SnacksDbContext.cs,Snacks/Models/MediaFile.cs—Priority,LastVerifiedAt,LastVerifyResultSnacks/Data/Migrations/20260610021037_AddQueuePriorityAndVerification.*,20260610024354_AddVerifyResult.*,SnacksDbContextModelSnapshot.csSnacks/wwwroot/js/queue/queue-manager.js,Snacks/wwwroot/js/queue/work-item-renderer.js
Manual queue actions remux at-target files
Snacks/Services/TranscodingService.cs—forceMuxonAddFileAsync/AddDirectoryAsync,NeedsContainerChange, force-mux skip-ladder/dispatch/mux-pass handling, hydration carry-throughSnacks/Services/ClusterService.cs— Transcode→Hybrid upgrade for force-mux items at dispatchSnacks/Controllers/LibraryController.cs—ProcessFile/ProcessDirectorypassforce/forceMuxSnacks/Models/WorkItem.cs,Snacks/Models/MediaFile.cs—ForceMuxSnacks/Data/MediaFileRepository.cs— sticky-true upsert, clear on completion/resetSnacks/Data/Migrations/20260615000045_AddForceMux.*,SnacksDbContextModelSnapshot.cs
Encoder correctness fixes
Snacks/Services/FfprobeService.cs—MapAudiowhole-file audio safeguard (never emit a no-audio output)Snacks/Services/TranscodingService.cs—SourceCodecMeetsTarget/CodecRankcodec-efficiency hierarchy across the skip ladder, analyze, dispatch, and mux-pass eligibility; source-codec skip labelsSnacks.Tests/Audio/AudioPlannerTests.cs— audio-safeguard cases (updated)Snacks.Tests/Video/EncodeSkipPredicateTests.cs— codec-hierarchy + AV1-under-HEVC skip (updated)
Library Health & rolling verification
Snacks/Controllers/LibraryHealthController.cs— newSnacks/Services/FileHealthService.cs,Snacks/Services/RollingVerificationService.cs— newSnacks/Controllers/LibraryController.cs— health/insights/verify + paged analyze endpoints +health/delete&health/delete-allSnacks/Data/MediaFileRepository.cs—GetHealthPathsAsync, sharedApplyHealthSearchSnacks/Models/Requests/HealthDeleteAllRequest.cs— newSnacks/Views/LibraryHealth/Index.cshtml,Snacks/wwwroot/js/health/library-health.js— new (Delete All button, per-row delete,.dash-tablestyling)Snacks/Views/Shared/_AdvancedSettings.cshtml— Rolling Verification panel
Dashboard & UI fixes
Snacks/Services/ClusterService.cs— persistNodeIdon first run (stable identity across restarts)Snacks/Data/EncodeHistoryRepository.cs— per-node throughput grouped by hostnameSnacks/Controllers/QueueController.cs,Snacks/Services/TranscodingService.cs—knownFilescount for first-run gatingSnacks/wwwroot/js/queue/queue-manager.js— onboarding hero gated onknownFilesSnacks/wwwroot/js/queue/work-item-renderer.js—getStatusLabel(spaces compound statuses)Snacks/wwwroot/js/dashboard/dashboard.js—.dash-file-innerwrapper (header alignment)Snacks/wwwroot/js/settings/presets.js— apply-confirmationSnacks/wwwroot/js/library/analyze-modal.js,Snacks/Views/Shared/_AppModals.cshtml— analyze table.dash-tablestylingSnacks/wwwroot/css/site.css—.status-nosavings,.dash-file-inner
Metrics
Snacks/Controllers/MetricsController.cs,Snacks/Models/LibraryInsights.cs— newSnacks/Services/AuthMiddleware.cs—/metricsexact-match exemption
Background library analysis
Snacks/Services/LibraryAnalysisJobService.cs— newSnacks/wwwroot/js/library/analyze-modal.js,Snacks/wwwroot/js/library/library-browser.js
Presets
Snacks/wwwroot/js/settings/presets.js— newSnacks/Controllers/SettingsController.cs— preset CRUD/export/importSnacks/wwwroot/js/api.js,Snacks/Views/Shared/_GeneralSettings.cshtml
Settings safety & reorganization
Snacks/Controllers/SettingsController.cs— deep-merge on saveSnacks/wwwroot/js/settings/encoder-form.js— armed-after-restore auto-save, sparse apply, legacy migrationSnacks/wwwroot/js/main.js— debounced auto-save, derived UI, confirmationSnacks/Views/Shared/_AppModals.cshtml— 13 → 8 tab consolidation, analyze UI, priority overrideSnacks/Models/EncoderOptions.cs,Snacks/Models/EncoderOptionsOverride.csSnacks/Views/Shared/_GeneralSettings.cshtml,_VideoSettings.cshtml,_AudioSettings.cshtml,_MuxSettings.cshtmlSnacks/wwwroot/js/settings/panels/scheduling-panel.js,panels/networking-panel.jsSnacks/Services/NetworkingSettingsService.cs
Cluster
Snacks/Models/ClusterConfig.cs,Snacks/Services/ClusterDiscoveryService.cs,Snacks/Services/ClusterService.csSnacks/Controllers/ClusterController.cs,Snacks/Controllers/ClusterAdminController.csSnacks/Services/Slots/SlotLedger.cs,Snacks/Services/Cluster/TransferThrottle.cs,Snacks/Services/Cluster/SharedStoragePathValidator.csSnacks/Services/ClusterFileTransferService.cs,Snacks/Services/ClusterNodeJobService.csSnacks/Views/Shared/_ClusterSettings.cshtml,Snacks/wwwroot/js/cluster/cluster-settings-form.js,cluster-dashboard.js,override-dialog.jsSnacks.Tests/Cluster/DiscoveryTokenTests.cs,SharedStorageRewriteTests.cs,SlotLedgerTests.cs— new
Media analysis, subtitles & notifications
Snacks/Services/MediaTypeDetector.cs,Snacks/Services/FfprobeService.csSnacks/Services/Ocr/Parsers/PgsParser.cs,Snacks/Services/Ocr/NativeOcrService.cs,Snacks/Services/SubtitleExtractionService.csSnacks/Services/NotificationService.cs,Snacks/Controllers/NotificationsController.csSnacks.Tests/Settings/MediaTypeDetectorTests.cs,Snacks.Tests/Video/EncodeSkipPredicateTests.cs,Snacks.Tests/Video/HardwareEncoderTests.cs,RateControlAndScaleTests.cs— new/updated
Security & UI hardening
Snacks/wwwroot/js/utils/dom.js,Snacks/wwwroot/js/utils/download.jsSnacks/wwwroot/js/core/signalr-client.js,Snacks/wwwroot/js/dashboard/dashboard.jsSnacks/wwwroot/css/site.css
e2e suite & build
e2e/README.md,e2e/lib.sh,e2e/generate-library.sh,e2e/watch-memory.sh,e2e/scenarios/01-04-*.sh— new.gitignore,build-and-export.bat
Other
Snacks/Program.cs— register new servicesSnacks/Services/FileService.cs,Snacks/Services/IntegrationService.cs
Version bumps
Snacks/Controllers/HomeController.cs— health endpoint versionSnacks/Services/ClusterDiscoveryService.cs—ClusterVersionprotocol bump to 2.14.0Snacks/Views/Shared/_Layout.cshtml— footer versionREADME.md— version badge and footerbuild-and-export.bat— Docker tag versionelectron-app/package.json/package-lock.json— version
Full documentation: README.md