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.shproduces a self-contained.dmgwith 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
applehardware 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 noav1_videotoolboxencoder yet, but the input is still hardware-decoded via-hwaccel videotoolbox. - Auto-detection -- on macOS, startup probes VideoToolbox via a test encode and selects
appleautomatically when available, mirroring the existing CUDA/QSV/AMF/VAAPI detection logic. - OCR libraries bundled into the .app -- the
bundle-ocr-mac.shbuild step copieslibtesseract+libleptonicaand all transitive dylibs under<basedir>/x64/, rewriting every internal path to@loader_path/.... The TesseractOCR loader'sCustomSearchPathis wired up at process start to find them. - Optional code-signing & notarization -- when
electron-app/.env.mac.localprovidesCSC_NAME,APPLE_ID,APPLE_APP_SPECIFIC_PASSWORD, andAPPLE_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 includeshevc_videotoolbox/h264_videotoolboxwhenappleis selected.
Image-Based Subtitle Pass-Through (MKV)
- New
PassThroughImageSubtitlesMkvoption (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
TesseractOCRNuGet ships only Windows DLLs and uses its own LibraryLoader (which appends.soto Windows DLL filenames on Linux), sodlopenwas looking forlibleptonica-1.83.1.dll.so/libtesseract53.dll.soand failing. The Dockerfile now apt-installslibtesseract5(Leptonica is pulled in transitively) and symlinks the system shared libs into/app/x64/under the names the loader expects.Program.csextends the existing macOSCustomSearchPathwiring to Linux so the loader looks in the right place. libdl.sosymlink -- TesseractOCR's[DllImport("libdl")]resolution broke on glibc 2.34+ where libdl was folded into libc and only the versionedlibdl.so.2ships at runtime. The Dockerfile now drops the unversioned linker symlink at the runtime path so the P/Invoke resolves without dragging inlibc6-dev.- Surface the real OCR load failure --
NativeOcrServicenow unwraps theTargetInvocationExceptionthat TesseractOCR throws on native-load failures, so the log line shows the actualDllNotFoundException/ missing-tessdata cause instead of a generic wrapper message. docker-compose.windows.ymloverride -- Docker Desktop on Windows runs containers inside a Linux VM, andnetwork_mode: hostbinds to the VM, not Windows. The new override switches to bridge mode and publishes port6767explicitly.start-snacksweb.batnow 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-streamDuration) can lie on broken sources and freshly-muxed outputs, producing false-positive mismatches and false-negative passes. A newGetAccurateVideoDurationAsyncseeks ffprobe to EOF and reads only the last 100 video packets (-read_intervals "99999%+#100"), then takes the maxpts_time + duration_timeas 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
blackdetectpass 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
FailedwithCompleted-- whenConvertVideoAsync's internal fail paths (e.g. the truncation-detected branch above) calledMarkWorkItemFailedand returned without throwing, the outer encode loop was unconditionally settingStatus = 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 isFailed. - 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
DELETEafter a completed transfer is lost (worker offline mid-handshake, transient network blip), the worker can keep advertising acurrentJobId/completedJobId/receivingJobIdfor 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 fromNodeTimeoutSeconds(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
TransferProgressmid-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 readX-Received-Bytes = <tiny number>and re-send chunk 0 withFileMode.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(withFileShare.ReadWriteso a concurrent metadata write doesn't crash the read) and resolve to a deterministic path. TheX-Original-FileNameheader remains as a fallback for callers that PUT without first POSTing metadata. - Don't delete
_metadata.jsonearly -- 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 earlyFile.Deleteis removed. FileNotFoundmid-write returns 409, not 500 -- when the partial file vanishes between the initial existence guard and a laterFileInfo.Length/FileMode.Opencall (cleanup tick, antivirus, Spotlight quarantine, an orphan handler), the receive handler now returns409 Conflictwith the current on-disk size. That drops into the master's offset-mismatch path which re-aligns and resumes — the previous500dropped into transient-retry which lost the resume context entirely.GetNodeReceivedBytesAsyncno longer swallows failures silently -- the barecatch { }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, theX-Received-Bytesheader 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 --
RunHeartbeatAsyncruns 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 onisMaster.
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 callsapp.requestSingleInstanceLock()at startup and focuses the existing window on a duplicate launch. The .NET backend has its ownFileShare.Nonelock at<workDir>/.snacks.lockas 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 bothstart-snacksweb.{bat,sh}scripts were probinghttp://localhost:6767/Home/Health, which doesn't exist. They now hit/api/health, so containers stop reportingunhealthyindefinitely while the app is in fact running. - EF Core migrations no longer silently gitignored -- the root
.gitignorehaddata/(unanchored), which Git was matching against the C# source directorySnacks/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-blocked20260422235839_AddMediaFileStreamSummariesmigration (which addsAudioStreams/SubtitleStreamscolumns to theMediaFilestable) is now committed.
Files Changed
macOS support
build-mac.sh(new) -- macOS DMG build orchestratorelectron-app/scripts/bundle-ocr-mac.sh(new) -- libtesseract / libleptonica bundling with@loader_pathrewriteselectron-app/scripts/notarize.js(new) -- afterSign hook for notarizationelectron-app/entitlements.mac.plist(new)electron-app/icons/snacks.icns,snacks.png(new)electron-app/main.js-- single-instance lock, mac path/launch wiringelectron-app/package.json-- mac build target,@electron/notarizedeprun-electron-dev.sh(new) -- mac dev mode launcherSnacks/Program.cs-- TesseractOCRCustomSearchPathfor macOS, work-dir file lockSnacks/Services/TranscodingService.cs-- VideoToolbox detection, init flags, encoder mappingSnacks/Services/ClusterDiscoveryService.cs--OsPlatform = "macOS"reporting + VideoToolbox encoder advertisementSnacks/Models/ClusterNode.cs-- doc comment update for the new platform valueSnacks/Views/Shared/_GeneralSettings.cshtml-- "Apple VideoToolbox (macOS)" dropdown optionREADME.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 reconciliationSnacks/Services/ClusterNodeJobService.cs-- removed syntheticWorkItemUpdatedbroadcasts on receive-state clear / displaceSnacks/Services/ClusterFileTransferService.cs-- diagnostic logging whenGetNodeReceivedBytesAsyncdegrades to 0Snacks/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 whitespaceSnacks/Services/FfprobeService.cs--IsTailMostlyBlackAsync+AnalyzeTailBlackAsyncfor tail-content validation, newGetAccurateVideoDurationAsyncpacket-tail probe, duration-overload ofConvertedSuccessfullySnacks/Services/TranscodingService.cs-- accept-on-blank-tail branch in the post-encode duration check using packet-accurate durations; don't overwriteFailedwithCompleted; bisection-based VAAPI QP calibration with adaptive slope; new "drop image-based subs only" retry tier;dropImageSubtitlesOnlyplumbed throughConvertVideoAsync/HandleConversionFailureSnacks/Models/ClusterConfig.cs-- newStaleReceiveTimeoutSeconds(default 8)
Image-based subtitle pass-through
Snacks/Models/EncoderOptions.cs-- newPassThroughImageSubtitlesMkvflagSnacks/Models/EncoderOptionsOverride.cs-- override slot for the new flagSnacks/Services/FfprobeService.cs--MapSubgains anincludeBitmapsparameterSnacks/Views/Shared/_SubtitleSettings.cshtml-- new toggle + help textSnacks/Views/Home/Index.cshtml-- override controls in folder/node dialogsSnacks/wwwroot/js/cluster/override-dialog.js,Snacks/wwwroot/js/settings/encoder-form.js-- field wiring
Linux / container OCR
Snacks/Dockerfile-- apt-installlibtesseract5, symlink leptonica/tesseract.sos into/app/x64/under the loader's expected names, drop unversionedlibdl.sosymlink, fixHEALTHCHECKURLSnacks/Program.cs-- extend TesseractOCRCustomSearchPathwiring to LinuxSnacks/Services/Ocr/NativeOcrService.cs-- unwrapTargetInvocationExceptionwhen reporting Tesseract engine load failuresdocker-compose.windows.yml(new) -- bridge-mode + 6767 publish for Docker Desktop on Windowsstart-snacksweb.bat,start-snacksweb.sh,docker-compose.yml,deploy-compose.yml,README.md-- healthcheck URL fix; Windows compose-override layering
Repo hygiene
.gitignore-- anchoreddata/to repo root so EF Core migrations underSnacks/Data/Migrations/are no longer silently excludedSnacks/Data/Migrations/20260422235839_AddMediaFileStreamSummaries.cs(+.Designer.cs) -- addsAudioStreams/SubtitleStreamscolumns toMediaFiles(was authored earlier in the cycle but blocked from being committed by the gitignore bug above)
Version Bumps
Snacks/Controllers/HomeController.csSnacks/Services/ClusterDiscoveryService.cs-- protocol version bump to 2.5.0Snacks/Views/Shared/_Layout.cshtmlREADME.mdbuild-and-export.batelectron-app/package.json/package-lock.json
Full documentation: README.md