github derekshreds/Snacks v2.5.0
Snacks v2.5.0

latest release: v2.5.1
19 hours ago

Snacks v2.5.0

Automated Video Library Encoder

A minor release that adds macOS (Apple Silicon) desktop support with VideoToolbox hardware encoding, opt-in image-based subtitle pass-through for MKV outputs (PGS/VOBSUB/DVB), and Linux/container OCR — Tesseract now works inside the Docker image. It also lands a round of cluster reliability work: packet-accurate duration validation that distinguishes blank trailing padding from real truncation, file-transfer hardening that fixes a macOS APFS truncation loop and a worker-issued DELETE that was tearing down master-delegated jobs, automatic recovery for workers wedged on stale state, single-instance locking to protect the work directory, and a rewritten VAAPI calibration that converges cleanly on encoders whose QP→bitrate curve doesn't match the old fixed heuristic.


New Features

macOS Desktop App (Apple Silicon)

  • Native Apple Silicon build -- new build-mac.sh produces a self-contained .dmg with the .NET 10 runtime, FFmpeg, and OCR libraries bundled into the .app. End users don't need Homebrew, .NET, or any other toolchain installed.
  • VideoToolbox hardware encoding -- new apple hardware option in the Hardware Acceleration dropdown. H.264 and HEVC encode in hardware (h264_videotoolbox / hevc_videotoolbox) with VBR rate control. AV1 falls back to software (libsvtav1) because ffmpeg has no av1_videotoolbox encoder yet, but the input is still hardware-decoded via -hwaccel videotoolbox.
  • Auto-detection -- on macOS, startup probes VideoToolbox via a test encode and selects apple automatically when available, mirroring the existing CUDA/QSV/AMF/VAAPI detection logic.
  • OCR libraries bundled into the .app -- the bundle-ocr-mac.sh build step copies libtesseract + libleptonica and all transitive dylibs under <basedir>/x64/, rewriting every internal path to @loader_path/.... The TesseractOCR loader's CustomSearchPath is wired up at process start to find them.
  • Optional code-signing & notarization -- when electron-app/.env.mac.local provides CSC_NAME, APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, and APPLE_TEAM_ID, the build signs and notarizes automatically; otherwise it produces an unsigned DMG (right-click → Open on first launch).
  • Cluster awareness -- worker capability advertisements now report OsPlatform = "macOS" alongside the existing Windows/Linux values; the supported-encoder list includes hevc_videotoolbox / h264_videotoolbox when apple is selected.

Image-Based Subtitle Pass-Through (MKV)

  • New PassThroughImageSubtitlesMkv option (off by default) -- when enabled, image-based subtitle tracks (PGS / VOBSUB / DVB) are copied straight into MKV outputs alongside any text-based and OCR'd tracks. Combine with the existing OCR option to ship both the original bitmap tracks and searchable SRT versions in the same file. Available in the Subtitles settings page and overridable per-folder / per-node.
  • MP4 always strips them -- the format does not officially support PGS muxing, so the option is ignored on MP4 outputs regardless of the toggle.
  • New retry tier in the failure chain -- bitmap streams are the most common cause of subtitle-related encode failures. When pass-through is on and an encode fails, the pipeline now tries dropping image-based subs only (keeping OCR'd SRTs and text-based tracks) before falling back to the existing strip-all-subtitles retry, so a single bad PGS track no longer costs the user every other subtitle.

Linux / Container OCR Support

  • Tesseract now loads inside the Docker image -- the TesseractOCR NuGet ships only Windows DLLs and uses its own LibraryLoader (which appends .so to Windows DLL filenames on Linux), so dlopen was looking for libleptonica-1.83.1.dll.so / libtesseract53.dll.so and failing. The Dockerfile now apt-installs libtesseract5 (Leptonica is pulled in transitively) and symlinks the system shared libs into /app/x64/ under the names the loader expects. Program.cs extends the existing macOS CustomSearchPath wiring to Linux so the loader looks in the right place.
  • libdl.so symlink -- TesseractOCR's [DllImport("libdl")] resolution broke on glibc 2.34+ where libdl was folded into libc and only the versioned libdl.so.2 ships at runtime. The Dockerfile now drops the unversioned linker symlink at the runtime path so the P/Invoke resolves without dragging in libc6-dev.
  • Surface the real OCR load failure -- NativeOcrService now unwraps the TargetInvocationException that TesseractOCR throws on native-load failures, so the log line shows the actual DllNotFoundException / missing-tessdata cause instead of a generic wrapper message.
  • docker-compose.windows.yml override -- Docker Desktop on Windows runs containers inside a Linux VM, and network_mode: host binds to the VM, not Windows. The new override switches to bridge mode and publishes port 6767 explicitly. start-snacksweb.bat now layers it on top of the base compose file. (Cluster UDP broadcast discovery still won't reach the Windows host from a containerized peer; that's a Docker Desktop networking limit, not a regression.)

Bug Fixes & Reliability

Output Validation (Master)

  • Packet-accurate duration probe -- container header metadata (Format.Duration / per-stream Duration) can lie on broken sources and freshly-muxed outputs, producing false-positive mismatches and false-negative passes. A new GetAccurateVideoDurationAsync seeks ffprobe to EOF and reads only the last 100 video packets (-read_intervals "99999%+#100"), then takes the max pts_time + duration_time as the real end-of-content. The post-encode check now compares those measured durations on both sides, so the comparison reflects what the streams actually contain. Fast even on multi-GB files.
  • Distinguish blank trailing padding from real truncation -- previously, any duration mismatch between source and encoded output triggered a permanent failure or repeated retries. The duration check now runs an ffmpeg blackdetect pass over the missing tail of the source: if ≥95% of the gap is black/blank (a common authoring artifact on Blu-ray rips and broadcast captures), the output is accepted. If the tail contains real content, the encode is failed permanently without retries — retries would produce the same truncation. The accept/fail log line now includes both measured durations for diagnosability.
  • Don't overwrite Failed with Completed -- when ConvertVideoAsync's internal fail paths (e.g. the truncation-detected branch above) called MarkWorkItemFailed and returned without throwing, the outer encode loop was unconditionally setting Status = Completed, Progress = 100, and firing the post-encode rescan/notification side effects against a job that had already been marked failed. The completion block now no-ops when status is Failed.
  • Master-side download validation simplified to a sanity probe -- the master no longer re-runs full encoding-correctness checks on downloaded outputs (that's the worker's job). It now confirms only that the file exists, is non-empty, parses as a media container, has a video stream, and has a non-zero duration. This removes a class of false-positive re-download loops where a successfully-encoded output looked "wrong" to the master because of metadata-level differences the worker had already accepted.

Cluster: Stuck-Worker Recovery

  • Auto-reset workers wedged on orphan jobs -- when the master's cleanup DELETE after a completed transfer is lost (worker offline mid-handshake, transient network blip), the worker can keep advertising a currentJobId/completedJobId/receivingJobId for a job the master no longer tracks. The dispatch loop now detects this case (worker reports a job ID, master has nothing for it) and issues a node reset before re-dispatching, instead of treating the worker as legitimately busy and indefinitely re-queuing work.
  • Re-attach to in-flight encodes after a master restart -- if the worker is mid-encode on the same work item the master is trying to dispatch, the master now re-registers the assignment and waits on the existing encode rather than uploading a fresh copy. Same for completed-but-not-yet-downloaded outputs (re-attach and pull via the normal completion path) and partial uploads (resume from the chunk-aligned offset via the existing receive-bytes endpoint).
  • Better failure logging on cleanup DELETEs -- the try { … DELETE … } catch { } swallow on every cleanup path was hiding the exact failures that lead to wedged workers. Each cleanup DELETE site now logs the exception and the URL with a note that the next dispatch tick will auto-reset the worker.

Cluster: Transfer State

  • Removed UI-flicker race on receive-state clear -- the worker used to broadcast a synthetic WorkItemUpdated → Completed/100% whenever it cleared _receivingJobId (either via the stale-receive timer or because a new receive displaced an old one). That raced with the master's own re-queue/dispatch messages and produced flicker in the queue UI. The master is now the sole source of lifecycle messaging; the worker only updates its internal receive state.
  • Independent stale-receive timeout -- StaleReceiveTimeoutSeconds (default 8s) is now a separate config value from NodeTimeoutSeconds (default 30s), so the worker self-clears stale receive state quickly between dispatch ticks even when the master's overall node-timeout is generous.
  • Don't requeue on duplicate dispatch -- when a second dispatch attempt finds an upload already in flight for the same work item, it now skips silently instead of requeuing. The previous behavior raced with the in-flight upload and zeroed TransferProgress mid-upload.

Cluster: File Transfer Hardening

  • Filename resolution now keys off _metadata.json, not directory enumeration -- the worker's HEAD-bytes and PUT-chunk handlers used to find the upload's filename by enumerating the temp dir and returning the first file that wasn't an encoded output or _metadata.json. On macOS APFS that order is non-deterministic, and a dot-file (.DS_Store, .fseventsd, Spotlight metadata) could shadow the real source. The master would then read X-Received-Bytes = <tiny number> and re-send chunk 0 with FileMode.Create, truncating the in-flight upload back to zero on every dispatch tick — an infinite re-upload loop. Both handlers now read the sanitized filename out of _metadata.json (with FileShare.ReadWrite so a concurrent metadata write doesn't crash the read) and resolve to a deterministic path. The X-Original-FileName header remains as a fallback for callers that PUT without first POSTing metadata.
  • Don't delete _metadata.json early -- the autonomous-encoding endpoint used to delete the metadata file as soon as the worker accepted the job, but the HEAD/PUT lookup above needs it for the duration of the transfer. The early File.Delete is removed.
  • FileNotFound mid-write returns 409, not 500 -- when the partial file vanishes between the initial existence guard and a later FileInfo.Length / FileMode.Open call (cleanup tick, antivirus, Spotlight quarantine, an orphan handler), the receive handler now returns 409 Conflict with the current on-disk size. That drops into the master's offset-mismatch path which re-aligns and resumes — the previous 500 dropped into transient-retry which lost the resume context entirely.
  • GetNodeReceivedBytesAsync no longer swallows failures silently -- the bare catch { } made it impossible to diagnose a stuck loop where the worker actually had data but the master was treating it as 0 (and therefore re-truncating from chunk 0). The fall-through to "0 bytes received" now logs the HTTP status, the X-Received-Bytes header value, and any exception, so the failure mode is visible in the log.

Cluster: Workers Mistakenly Cancelling Master-Delegated Jobs

  • Heartbeat reconciliation is now master-only -- RunHeartbeatAsync runs on every node so each one's UI shows accurate cluster status, but the reconciliation blocks (cancel-unknown-job DELETEs, requeue-on-failure, idle detection, progress sync against _remoteJobs) all read or mutate dispatch state that only the master maintains. A worker running them was issuing cross-node DELETEs that wiped jobs the master had legitimately running on the targeted peer — including deleting the file the master was actively uploading. Display updates (node.Status, node.ActiveWorkItemId, LastHeartbeat) remain unconditional so worker UIs render correctly; everything that mutates dispatch state is now gated on isMaster.

Single-Instance Lock

  • Two Snacks instances can no longer share a work directory -- a second instance pointed at the same per-user work dir would race the first on the SQLite WAL and on remote-jobs/<jobId>/ temp files (one instance's cleanup deletes the other's in-flight upload). The Electron app now calls app.requestSingleInstanceLock() at startup and focuses the existing window on a duplicate launch. The .NET backend has its own FileShare.None lock at <workDir>/.snacks.lock as a fallback for launches that bypass Electron (CLI, tests, headless dev runs).

VAAPI Calibration

  • Bisection-based QP search with adaptive slope -- the calibration loop's old fixed model (each +2 QP ≈ 0.72× bitrate, lifted from x264) overshoots on most VAAPI encoders, where the real curve is closer to ~0.5× per +2 QP. The result was a calibration that bounced past the target on every step and burned passes oscillating between two QPs that straddled it.
    • Once the search has tested QPs that bracket the target (one above, one below), it now bisects within the bracket — converges in ~log2(range) passes and is immune to the encoder's nonlinear curve.
    • When no bracket exists yet, it fits the slope from prior samples (≥2 needed) instead of using the fixed 0.72× constant; falls back to the old constant only when the fit produces a positive or near-zero slope (measurement noise).
    • Extrapolation steps are clamped to ±4 QP so a long extrapolation can't skip past the bracket.
    • Termination conditions are now explicit: adjacent-bracket convergence, exhausted bracket, and exhausted novel-QP range all log a clear reason and select the best observed QP.

Deployment & Repo Hygiene

  • Healthcheck endpoint corrected -- the Docker HEALTHCHECK, the three compose files, and both start-snacksweb.{bat,sh} scripts were probing http://localhost:6767/Home/Health, which doesn't exist. They now hit /api/health, so containers stop reporting unhealthy indefinitely while the app is in fact running.
  • EF Core migrations no longer silently gitignored -- the root .gitignore had data/ (unanchored), which Git was matching against the C# source directory Snacks/Data/Migrations/ and quietly excluding every new migration file from commits. Anchored to /data/ so it only matches the runtime data folder at the repo root. The previously-blocked 20260422235839_AddMediaFileStreamSummaries migration (which adds AudioStreams / SubtitleStreams columns to the MediaFiles table) is now committed.

Files Changed

macOS support

  • build-mac.sh (new) -- macOS DMG build orchestrator
  • electron-app/scripts/bundle-ocr-mac.sh (new) -- libtesseract / libleptonica bundling with @loader_path rewrites
  • electron-app/scripts/notarize.js (new) -- afterSign hook for notarization
  • electron-app/entitlements.mac.plist (new)
  • electron-app/icons/snacks.icns, snacks.png (new)
  • electron-app/main.js -- single-instance lock, mac path/launch wiring
  • electron-app/package.json -- mac build target, @electron/notarize dep
  • run-electron-dev.sh (new) -- mac dev mode launcher
  • Snacks/Program.cs -- TesseractOCR CustomSearchPath for macOS, work-dir file lock
  • Snacks/Services/TranscodingService.cs -- VideoToolbox detection, init flags, encoder mapping
  • Snacks/Services/ClusterDiscoveryService.cs -- OsPlatform = "macOS" reporting + VideoToolbox encoder advertisement
  • Snacks/Models/ClusterNode.cs -- doc comment update for the new platform value
  • Snacks/Views/Shared/_GeneralSettings.cshtml -- "Apple VideoToolbox (macOS)" dropdown option
  • README.md -- macOS install / build / signing docs, supported-platform table, project layout

Cluster reliability

  • Snacks/Services/ClusterService.cs -- stuck-worker auto-reset, in-flight encode/upload re-attachment, duplicate-dispatch guard, sanity-probe-only output validation, cleanup-DELETE error logging, master-only gating of heartbeat reconciliation
  • Snacks/Services/ClusterNodeJobService.cs -- removed synthetic WorkItemUpdated broadcasts on receive-state clear / displace
  • Snacks/Services/ClusterFileTransferService.cs -- diagnostic logging when GetNodeReceivedBytesAsync degrades to 0
  • Snacks/Controllers/ClusterController.cs -- HEAD/PUT filename resolved from _metadata.json, FileNotFound → 409 with current size, removed early metadata-file delete; full file reformatted (indentation only) so the diff is large but most of it is whitespace
  • Snacks/Services/FfprobeService.cs -- IsTailMostlyBlackAsync + AnalyzeTailBlackAsync for tail-content validation, new GetAccurateVideoDurationAsync packet-tail probe, duration-overload of ConvertedSuccessfully
  • Snacks/Services/TranscodingService.cs -- accept-on-blank-tail branch in the post-encode duration check using packet-accurate durations; don't overwrite Failed with Completed; bisection-based VAAPI QP calibration with adaptive slope; new "drop image-based subs only" retry tier; dropImageSubtitlesOnly plumbed through ConvertVideoAsync / HandleConversionFailure
  • Snacks/Models/ClusterConfig.cs -- new StaleReceiveTimeoutSeconds (default 8)

Image-based subtitle pass-through

  • Snacks/Models/EncoderOptions.cs -- new PassThroughImageSubtitlesMkv flag
  • Snacks/Models/EncoderOptionsOverride.cs -- override slot for the new flag
  • Snacks/Services/FfprobeService.cs -- MapSub gains an includeBitmaps parameter
  • Snacks/Views/Shared/_SubtitleSettings.cshtml -- new toggle + help text
  • Snacks/Views/Home/Index.cshtml -- override controls in folder/node dialogs
  • Snacks/wwwroot/js/cluster/override-dialog.js, Snacks/wwwroot/js/settings/encoder-form.js -- field wiring

Linux / container OCR

  • Snacks/Dockerfile -- apt-install libtesseract5, symlink leptonica/tesseract .sos into /app/x64/ under the loader's expected names, drop unversioned libdl.so symlink, fix HEALTHCHECK URL
  • Snacks/Program.cs -- extend TesseractOCR CustomSearchPath wiring to Linux
  • Snacks/Services/Ocr/NativeOcrService.cs -- unwrap TargetInvocationException when reporting Tesseract engine load failures
  • docker-compose.windows.yml (new) -- bridge-mode + 6767 publish for Docker Desktop on Windows
  • start-snacksweb.bat, start-snacksweb.sh, docker-compose.yml, deploy-compose.yml, README.md -- healthcheck URL fix; Windows compose-override layering

Repo hygiene

  • .gitignore -- anchored data/ to repo root so EF Core migrations under Snacks/Data/Migrations/ are no longer silently excluded
  • Snacks/Data/Migrations/20260422235839_AddMediaFileStreamSummaries.cs (+ .Designer.cs) -- adds AudioStreams / SubtitleStreams columns to MediaFiles (was authored earlier in the cycle but blocked from being committed by the gitignore bug above)

Version Bumps

  • Snacks/Controllers/HomeController.cs
  • Snacks/Services/ClusterDiscoveryService.cs -- protocol version bump to 2.5.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.