github derekshreds/Snacks v2.14.0
Snacks v2.14.0

13 hours ago

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. SyncQueueWindowAsync reconciles the window against the DB top-N, gated by a _queueWindowDirty flag and _windowSyncLock so a tick with nothing to do is free. It evicts items that fell out of top-N (their rows stay Queued), lazily hydrates new ones, and quarantines vanished sources to Unseen.
  • 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 by QueueWindowSize per 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 through RegisterWorkItem / UnregisterWorkItem / ClearWorkItems / FindWorkItemByPath.
  • Terminal-item memory cap (TerminalWorkItemCap = 1000) — a new 5-minute _workItemSweepTimer runs SweepTerminalWorkItems, releasing Probe on items quiet for >2 minutes, then evicting the oldest terminal items beyond the cap (with a re-check so a requeued Stopped item isn't lost). RequeueWorkItem now re-registers unconditionally to heal sweep-vs-revive races.
  • KickQueueAsync replaces RestoreToQueueAsync — 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 — throttled QueueChanged SignalR 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.
  • WorkItem model — new int Priority and DateTime QueuedAt (a newest-first tiebreaker distinct from object-build CreatedAt). Path is backed by _path with a cached [JsonIgnore] NormalizedPath to kill repeated GetFullPath calls, and Probe is 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 a Failed terminal status. Previously a stalled standalone encode could sit in Processing forever.
  • PruneMissingWorkItemsAsync / DropMissingWorkItemAsync — drop work items whose source vanished (removing the memory entry, the DB row, and pushing WorkItemRemoved), skipping active encodes. The dispatch loop also drops missing sources at dispatch time.
  • EncodedBitrateKbps bug fix — now divides by 1000 (decimal kbit) instead of 1024, matching every other bitrate in the app.

Scan side: chunked, checkpointed, resumable, parallel

  • AutoScanService chunked sweep — each watched tree is walked in DirChunkSize = 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 to scan-checkpoint.json after 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 processingProcessDiscoveredFileAsync runs under Parallel.ForEachAsync with MaxDegreeOfParallelism = Clamp(ProcessorCount / 2, 2, 8) (the old sequential first sweep "took days"). Companion-completed marks are collected in a ConcurrentBag and flushed as one batched SetStatusBatchAsync per chunk. Progress is reported per-chunk via a ScanProgress SignalR event, not per-file.
  • DB-first startup resumeResumeLocalQueueItemsAsync now just requeues orphaned local Processing rows, counts queued local rows, marks the window dirty, and kicks the queue — no per-row replay.
  • Concurrency / robustness_config.Directories is snapshotted under _configLock (fixes "Collection was modified"); ClearHistory guards _scanCts.Cancel() against ObjectDisposedException and 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 via PruneMissingWorkItemsAsync.
  • 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-Queued rows, supports newest-first, pages slice + total, refuses to bump a non-Queued row, guards status flips to Queued-only, requeues only orphaned local Processing rows on restart, resets all queued to Unseen and clears priority, hydrates/quarantines idempotently, honors the kind filter, and parses mf- 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}QueueControllerTranscodingService.PrioritizeWorkItemAsync. Accepts an mf-{rowId} or a GUID; the authoritative write is MediaFileRepository.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 optionEncoderOptions.QueueNewestFirst (default false) flips the in-priority tiebreaker from bitrate to recency, surfaced as "Newest Files First" in general settings and wired through SetQueueOrderNewestFirst.
  • Per-folder priorityEncoderOptionsOverride.QueuePriority (int?) sets a base priority for a watched folder; exposed via an ovr_QueuePriority override control.
  • DB-sourced listingGET /api/queue/items is now async: pending rows come from the DB (GetQueuedPageAsync / CountQueuedLocalAsync), terminal items from memory, and active paths are filtered out of pending. Rows project as id = "mf-{Id}" via ToPendingDto. GET /api/queue/stats runs through GetWorkItemCountsAsync (pending from one indexed COUNT).
  • queue-manager.js — additive reconciliation of _workItems (no clear-then-refill), throttled refresh distinguishing full vs queue-only refetches, new QueueChanged / ScanProgress handlers, and a first-run onboarding empty state ("Welcome to Snacks").

DB columns & repository

  • Migration 20260610021037_AddQueuePriorityAndVerification — adds Priority (INTEGER NOT NULL default 0) and LastVerifiedAt (TEXT nullable), plus indexes IX_MediaFiles_Status_Priority_Bitrate (Status asc, Priority desc, Bitrate desc) and IX_MediaFiles_LastVerifiedAt.
  • Migration 20260610024354_AddVerifyResult — adds LastVerifyResult (TEXT, max 2048, nullable). Both mirrored in SnacksDbContext and Models/MediaFile.
  • MediaFileRepository (new)IDbContextFactory context-per-op with WAL and a SaveChangesWithRetryAsync (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. UpsertAsync only copies a non-zero Priority, so a rescan never erases a move-to-front. RemoveByPathAsync removes 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) + migration AddForceMux — 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 in UpsertAsync; cleared on terminal completion (SetStatusAndLastEncodedAtAsync) and on file reset (ResetFileAsync, ResetAllQueuedAsync). Hydrated onto the rebuilt WorkItem in SyncQueueWindowAsync.
  • 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 output Format (treating mp4/m4v as 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 — new forceMux parameter. 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. AddDirectoryAsync also now passes force: true, matching the long-standing ProcessFile behavior 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's EncodingMode Transcode→Hybrid before the pre-dispatch skip gate. On the cluster path the upgraded options are cloned into JobMetadata.Options, so the worker mux-passes correctly with no protocol change.
  • LibraryController.ProcessFile / ProcessDirectory — pass force: 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.MapAudio whole-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 new Snacks.Tests/Audio/AudioPlannerTests cases (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 ≤ …"). New EncodeSkipPredicateTests pin 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 the GET /library-health view; 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 with SemaphoreSlim(2), enforces a 90-second per-sample watchdog that kills the process tree, builds commands with ArgumentList (no option injection), and returns a deduped, truncated VerifyResult(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 reads VerifyFilesPerDay from settings.json (0 disables; default 0), verifies max(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 LibraryControllerGET /api/library/health (filters no-audio|no-video|no-duration|failed|verify-failed, plus q, skip, limit ≤ 500; the summary uses whole-library SQL counts), GET /api/library/insights, and POST /api/library/health/verify for 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.
  • SettingEncoderOptions.VerifyFilesPerDay (default 0), surfaced in _AdvancedSettings.cshtml as "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) and POST /api/library/health/delete-all (bulk by filter + q, capped at 5000/request and reporting deleted/failed/capped).
  • Disk delete + verified DB removalTryDeleteFlaggedAsync refuses any path outside the allowed library root, deletes the file via the retry-aware FileService.FileDeleteAsync, and only removes the MediaFile row once the file is confirmed gone — so a re-downloaded file is rediscovered as a fresh Unseen entry on the next scan, and a failed delete (locked/permissions) leaves the row and its health flag intact. EncodeHistory rows are left untouched so dashboard stats stay accurate. New MediaFileRepository.GetHealthPathsAsync returns the full matching path set for the bulk op.

Tests

  • Snacks.Tests/Cluster/... and repository queries back the health/verification queries (GetVerificationCandidatesAsync oldest-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 in AuthMiddleware (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 including snacks_scan_last_completed_timestamp_seconds. Everything is an aggregate — no paths or PII are exposed.
  • Models/LibraryInsights (new)TotalFiles / TotalBytes / HdrFiles / MusicFiles plus Codecs / Resolutions / Statuses lists of nested Slice(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 at FullResultCap = 20_000 (beyond which the job is Truncated, keeps 1000 preview rows, and reports authoritative per-decision totals in Summary), with 30-minute retention.
  • LibraryController analyze endpoints reworkedPOST /api/library/analyze-directory is fire-and-forget returning {success, jobId} (409 if one is running); new GET analyze-status/{id}, GET analyze-results/{id} (409 while running), and POST analyze-cancel/{id}. GET /api/library/files is now paged (skip / limit ≤ 5000) returning {files, total, videoTotal, musicTotal, truncated}.
  • JSanalyze-modal.js rewritten to start → poll (750ms) → fetch results with a progress bar and truncation notice; library-browser.js switched 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 in config/presets.json, with export/import as .snacks-preset.json.
  • SettingsController endpointsGET/POST presets, DELETE presets/{name}, GET presets/export/{name} (with a snacksPreset=1 marker), and POST presets/import. Backed by SavedPreset / PresetRequest, MaxPresets = 50, case-insensitive overwrite, and an atomic write-then-rename under _presetsLock.
  • api.js gains presetsApi; _GeneralSettings.cshtml gains 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 saveSettingsController.SaveSettings now merges the form payload over the existing settings.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 restoreencoder-form.js arms 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 new reportAutoSaveStatus / #settingsAutoSaveStatus indicator surfaces save state.
  • Strictly-sparse form application — the new applyEncoderOptionsToForm leaves inputs untouched for absent keys, so applying a preset doesn't wipe unrelated fields; sel() treats "" as missing and num() clamps to the input's min/max. A LEGACY_SELECT_VALUES migration maps old values forward (DownscalePolicy IfLarger → CapAtTarget; HardwareAcceleration nvenc/cuda → nvidia, vaapi/qsv → intel, amf → amd).
  • main.js — debounced encoder auto-save (600ms) with a NON_ENCODER_ID_PREFIXES exclusion list, an explicit confirmation on the "Replace Original Files" toggle, and derived-UI handlers (syncCodecForFormat disables codec when Format = webm; syncMuxOnlyNotice).
  • Tab consolidation_AppModals.cshtml collapses 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 settingsEncoderOptions gains EncodingLogRetentionDays (7), VerifyFilesPerDay (0), and QueueNewestFirst (false), all included in Clone. EncoderOptionsOverride.AudioLanguagesToKeep / SubtitleLanguagesToKeep are now copied into new lists rather than aliased, so dispatch-time merges no longer mutate saved config.
  • Panel fixesscheduling-panel.js warns when a schedule window has no days selected; networking-panel.js clamps chunk size to its min/max.

Cluster

Multi-share path rewrites, hardened discovery, and reliability fixes

  • ClusterConfig — new SharedStorageRewriteFrom / SharedStorageRewriteTo and a List<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 with FixedTimeEquals) instead of a brute-forceable SHA-256 of the secret, falling back to the legacy secretHash for mixed-version clusters. PerformHandshakeAsync returns bool and only reports success on a real handshake, so the register loop no longer falsely flips registered when the master is unreachable.
  • ClusterService — a non-reentrant heartbeat (Interlocked guard) 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.ActiveJobs mutations are wrapped in lock(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 — new Rekey(old, new) for the atomic reservation move on upload-resume.
  • ClusterController — multi-worker-per-host disambiguation (prefer the node matching GetRemoteJobAssignedNodeId); ReceiveFile's size limit raised to TransferLimits.MaxChunkRequestBytes, fixing 413s on large chunks. ClusterAdminController exposes SharedStoragePathRewrites.
  • Transfer plumbing — new TransferLimits static (Min 4MB / Max 256MB / +8MB header headroom) is the single source for chunk bounds (also used by NetworkingSettingsService); ClusterFileTransferService reuses a single chunk buffer with an EOF guard for a source that shrank mid-upload; ClusterNodeJobService fixes a receive-lock semaphore/CTS leak and a double slot-release.
  • JS / view_ClusterSettings.cshtml uses a single from => to textarea; cluster-settings-form.js parses and validates it (rejecting malformed lines loudly and clearing the legacy fields); cluster-dashboard.js skips background fetches when the panel is unmounted.

Tests

  • Snacks.Tests/Cluster/DiscoveryTokenTests.cs, SharedStorageRewriteTests.cs, and SlotLedgerTests.cs (all new) — token bucket/equality, longest-From-first rewrite ordering, and slot semantics including a 32-way concurrent reserve picking a single winner plus Rekey behavior.

Media analysis, subtitle & notification fixes

  • MediaTypeDetector.ExtractMovieTitle — now finds the year before stripping brackets so Title (2010) keeps its year, and uses the last YearPattern match at index > 0 to handle 2001 A Space Odyssey (1968) and 1917.mkv. Covered by the new MediaTypeDetectorTests.
  • FfprobeService — three FFmpeg/ffprobe calls switched to ArgumentList (filename arg-injection hardening) and gained a 5-minute watchdog that throws TimeoutException on a hung probe, instead of holding the scan lock forever (a hang could yield an empty ProbeResult and 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 / SubtitleExtractionServiceArgumentList hardening, and a failed extraction now deletes its partial sidecar instead of leaving a truncated .srt / .ass for Plex/Jellyfin.
  • NotificationService / NotificationsController — new SendTestAsync(dest); an apprise://http:// and apprises://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.js escapeHtml now 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.
  • /metrics auth — exact-match allowlist in AuthMiddleware so the metrics path is exempt without opening a prefix.
  • HMAC cluster discovery — see the Cluster section above.
  • download.js adds a double-click re-entry guard; dashboard.js removes a leaked #dashTooltip on 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), and watch-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 Processing rows.
    • 04 — priority: move-to-front changes the real dispatch order.
  • .gitignore ignores e2e/.build and e2e/.runs.

Dashboard & UI fixes

  • Stable per-node throughputClusterConfig.NodeId defaulted to a fresh GUID and a standalone install never wrote cluster.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.LoadConfig now 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 NoSavings status rendered as a run-on, uncolored "NOSAVINGS". A getStatusLabel helper spaces compound status names ("No Savings"), a .status-nosavings color rule was added, and the Library Overview "Processing Status" labels are humanized the same way.
  • Dashboard table alignment.dash-file set display:flex directly 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-inner wrapper, so headers line up with the body.
  • Consistent table styling — the Library Health and Analyze (dry-run) tables now use the dashboard's .dash-table look (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 knownFiles count (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 — registers LibraryAnalysisJobService and FileHealthService (singletons) and RollingVerificationService (hosted) alongside the existing LogRetentionService.
  • FileService — working-directory fallback from /app/work to LocalApplicationData/Snacks/work for 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 drop
  • Snacks/Services/AutoScanService.cs — chunked/checkpointed/parallel resumable sweep, DB-first resume
  • Snacks/Models/WorkItem.csPriority, QueuedAt, cached NormalizedPath, [JsonIgnore] Probe
  • Snacks/Controllers/HomeController.cs — stop materializing the queue into the page model; version bump
  • Snacks.Tests/Pipeline/DbQueueTests.cs, Snacks.Tests/Pipeline/QueueOrderTests.cs — new
  • Snacks.Tests/Pipeline/FullCommandScenarioTests.cs — updated

Queue priority & DB schema

  • Snacks/Controllers/QueueController.csprioritize endpoint, DB-sourced listing/stats
  • Snacks/Data/MediaFileRepository.cs — new repository (queue, verification, health queries)
  • Snacks/Data/SnacksDbContext.cs, Snacks/Models/MediaFile.csPriority, LastVerifiedAt, LastVerifyResult
  • Snacks/Data/Migrations/20260610021037_AddQueuePriorityAndVerification.*, 20260610024354_AddVerifyResult.*, SnacksDbContextModelSnapshot.cs
  • Snacks/wwwroot/js/queue/queue-manager.js, Snacks/wwwroot/js/queue/work-item-renderer.js

Manual queue actions remux at-target files

  • Snacks/Services/TranscodingService.csforceMux on AddFileAsync/AddDirectoryAsync, NeedsContainerChange, force-mux skip-ladder/dispatch/mux-pass handling, hydration carry-through
  • Snacks/Services/ClusterService.cs — Transcode→Hybrid upgrade for force-mux items at dispatch
  • Snacks/Controllers/LibraryController.csProcessFile/ProcessDirectory pass force/forceMux
  • Snacks/Models/WorkItem.cs, Snacks/Models/MediaFile.csForceMux
  • Snacks/Data/MediaFileRepository.cs — sticky-true upsert, clear on completion/reset
  • Snacks/Data/Migrations/20260615000045_AddForceMux.*, SnacksDbContextModelSnapshot.cs

Encoder correctness fixes

  • Snacks/Services/FfprobeService.csMapAudio whole-file audio safeguard (never emit a no-audio output)
  • Snacks/Services/TranscodingService.csSourceCodecMeetsTarget / CodecRank codec-efficiency hierarchy across the skip ladder, analyze, dispatch, and mux-pass eligibility; source-codec skip labels
  • Snacks.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 — new
  • Snacks/Services/FileHealthService.cs, Snacks/Services/RollingVerificationService.cs — new
  • Snacks/Controllers/LibraryController.cs — health/insights/verify + paged analyze endpoints + health/delete & health/delete-all
  • Snacks/Data/MediaFileRepository.csGetHealthPathsAsync, shared ApplyHealthSearch
  • Snacks/Models/Requests/HealthDeleteAllRequest.cs — new
  • Snacks/Views/LibraryHealth/Index.cshtml, Snacks/wwwroot/js/health/library-health.js — new (Delete All button, per-row delete, .dash-table styling)
  • Snacks/Views/Shared/_AdvancedSettings.cshtml — Rolling Verification panel

Dashboard & UI fixes

  • Snacks/Services/ClusterService.cs — persist NodeId on first run (stable identity across restarts)
  • Snacks/Data/EncodeHistoryRepository.cs — per-node throughput grouped by hostname
  • Snacks/Controllers/QueueController.cs, Snacks/Services/TranscodingService.csknownFiles count for first-run gating
  • Snacks/wwwroot/js/queue/queue-manager.js — onboarding hero gated on knownFiles
  • Snacks/wwwroot/js/queue/work-item-renderer.jsgetStatusLabel (spaces compound statuses)
  • Snacks/wwwroot/js/dashboard/dashboard.js.dash-file-inner wrapper (header alignment)
  • Snacks/wwwroot/js/settings/presets.js — apply-confirmation
  • Snacks/wwwroot/js/library/analyze-modal.js, Snacks/Views/Shared/_AppModals.cshtml — analyze table .dash-table styling
  • Snacks/wwwroot/css/site.css.status-nosavings, .dash-file-inner

Metrics

  • Snacks/Controllers/MetricsController.cs, Snacks/Models/LibraryInsights.cs — new
  • Snacks/Services/AuthMiddleware.cs/metrics exact-match exemption

Background library analysis

  • Snacks/Services/LibraryAnalysisJobService.cs — new
  • Snacks/wwwroot/js/library/analyze-modal.js, Snacks/wwwroot/js/library/library-browser.js

Presets

  • Snacks/wwwroot/js/settings/presets.js — new
  • Snacks/Controllers/SettingsController.cs — preset CRUD/export/import
  • Snacks/wwwroot/js/api.js, Snacks/Views/Shared/_GeneralSettings.cshtml

Settings safety & reorganization

  • Snacks/Controllers/SettingsController.cs — deep-merge on save
  • Snacks/wwwroot/js/settings/encoder-form.js — armed-after-restore auto-save, sparse apply, legacy migration
  • Snacks/wwwroot/js/main.js — debounced auto-save, derived UI, confirmation
  • Snacks/Views/Shared/_AppModals.cshtml — 13 → 8 tab consolidation, analyze UI, priority override
  • Snacks/Models/EncoderOptions.cs, Snacks/Models/EncoderOptionsOverride.cs
  • Snacks/Views/Shared/_GeneralSettings.cshtml, _VideoSettings.cshtml, _AudioSettings.cshtml, _MuxSettings.cshtml
  • Snacks/wwwroot/js/settings/panels/scheduling-panel.js, panels/networking-panel.js
  • Snacks/Services/NetworkingSettingsService.cs

Cluster

  • Snacks/Models/ClusterConfig.cs, Snacks/Services/ClusterDiscoveryService.cs, Snacks/Services/ClusterService.cs
  • Snacks/Controllers/ClusterController.cs, Snacks/Controllers/ClusterAdminController.cs
  • Snacks/Services/Slots/SlotLedger.cs, Snacks/Services/Cluster/TransferThrottle.cs, Snacks/Services/Cluster/SharedStoragePathValidator.cs
  • Snacks/Services/ClusterFileTransferService.cs, Snacks/Services/ClusterNodeJobService.cs
  • Snacks/Views/Shared/_ClusterSettings.cshtml, Snacks/wwwroot/js/cluster/cluster-settings-form.js, cluster-dashboard.js, override-dialog.js
  • Snacks.Tests/Cluster/DiscoveryTokenTests.cs, SharedStorageRewriteTests.cs, SlotLedgerTests.cs — new

Media analysis, subtitles & notifications

  • Snacks/Services/MediaTypeDetector.cs, Snacks/Services/FfprobeService.cs
  • Snacks/Services/Ocr/Parsers/PgsParser.cs, Snacks/Services/Ocr/NativeOcrService.cs, Snacks/Services/SubtitleExtractionService.cs
  • Snacks/Services/NotificationService.cs, Snacks/Controllers/NotificationsController.cs
  • Snacks.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.js
  • Snacks/wwwroot/js/core/signalr-client.js, Snacks/wwwroot/js/dashboard/dashboard.js
  • Snacks/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 services
  • Snacks/Services/FileService.cs, Snacks/Services/IntegrationService.cs

Version bumps

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

Full documentation: README.md

Don't miss a new Snacks release

NewReleases is sending notifications on new releases.