Snacks v2.9.0
Automated Video Library Encoder
A minor release built around per-node transcode scheduling — every cluster member (master + workers) gets a weekly window of "allowed to accept new jobs" hours, configured from a new Scheduling tab in Settings and gated by a pure ScheduleEvaluator on the master's local clock. Off-schedule nodes drop out of the dispatch candidate pool, get an "Off-schedule" badge on the dashboard, and resume automatically when their window reopens — in-flight jobs run to completion regardless. A node warm-up grace period (10s) holds new dispatches off freshly-joined workers so their HTTP endpoints are up before the master pings them, ending the "first job after join sits in Processing for five minutes until the watchdog rescues it" pattern. Two encoder fixes ride along: Intel iGPUs without an HEVC low-power entrypoint (pre-Tiger Lake, Proxmox LXC without GuC firmware) now correctly fall through from LP → normal mode → software instead of returning a bogus "calibration complete" verdict that ffmpeg then refused to honor; and the post-encode notification path no longer throws FileNotFoundException ("Finalize failed: Could not find file …") when DeleteOriginalFile=true and the source/output extensions differ (.mp4 → .mkv).
Per-node scheduling
ScheduleEvaluator — pure, testable gate
Snacks/Services/ScheduleEvaluator.cs(new) — staticIsWithinAny(windows, localNow)/IsWithin(window, localNow)decide whether "now" falls inside any configured window. No DI, no clock — takes aDateTimeso tests drive it with synthetic values. Rules: empty/null window list = always allowed (pre-feature behavior); emptyDayslist = never matches;Start == End= full 24 hours on each listed day;Start < End= same-day[Start, End)window with exclusive end so adjacent windows can butt up without a one-minute overlap;Start > End= wraps past midnight, so a 22:00–06:00 window opens on the listed day and closes on the next day.Snacks.Tests/Cluster/ScheduleEvaluatorTests.cs(new) — 17 tests pinning each branch: null/empty schedules, same-day inside/at-start/at-end/wrong-day, wrap-around (Sunday 22:00 → Monday 06:00, the Monday morning leg, wrong day, between windows), 24-hour00:00 → 00:00, malformedHH:mmstrings (defensive — a bad config row shouldn't silently open or close the gate), and the multi-window OR composition.
NodeSettings.Schedule + ClusterNode.OffSchedule
NodeSettings.Schedule(new field) — optionalList<ScheduleWindow>?per node.ScheduleWindowis{ Days: List<DayOfWeek>, Start: "HH:mm", End: "HH:mm" }, master local time. Null/empty = always available. Persisted in the existingnodeSettings.jsonalongsideOnly4K/Exclude4K/EncodingOverrides/DeviceSettings.ClusterNode.OffSchedule(new transient flag) — set by the master on every dispatch tick and on settings save, broadcast over the existingWorkerUpdatedSignalR channel so the dashboard renders an "Off-schedule" badge without needing its own clock or schedule logic. Alwaysfalsewhen no schedule is configured. Master itself is not in_nodes, soClusterController.GetStatusandClusterAdminController.GetClusterStatusstamp it explicitly on the self-card payload.
Dispatch gate
ClusterService.IsNodeWithinSchedule/IsNodeWithinScheduleById— wired into all three candidate-pool filters (hasAvailableNodesearly-exit, per-iterationavailableNow,SelectBestSlotForJob). Off-schedule nodes drop out of dispatch; in-flight jobs on a now-off-schedule node are untouched.RefreshOffScheduleFlags(new master-only) — recomputes every node'sOffSchedule, firesWorkerUpdatedfor any node whose value flipped, and tracks the master's own off→on schedule transition. Called from the 2-second dispatch timer and from theSaveNodeSettingshook (so a schedule edit reflects on the dashboard within ms instead of waiting up to 2s for the next tick).- Local encoding gate (
TranscodingService.SetLocalScheduleGate) —ClusterService.StartAsyncinjects() => IsNodeWithinScheduleById(_config.NodeId)into the local encoder;ProcessQueueAsyncchecks the gate alongside the existing_localEncodingPausedcheck and exits cleanly when the master's window is closed (workers in their own open windows still pick up dispatched items).WakeFromSchedulere-triggersProcessQueueAsyncon the off→on edge so pending items resume without waiting for an external wake (queue add, settings change, etc.).
Settings UI: the Scheduling tab
Snacks/Views/Shared/_SchedulingSettings.cshtml(new) — collapsible section per node (master first, then workers). Each section holds a list of "allowed-to-transcode" rows, each row = 7 day-of-week chips + start/end<input type="time">. Master-clock banner at the top so a UTC-defaulted server doesn't trip up users configuring "evening" windows expecting their local wall clock.Snacks/wwwroot/js/settings/panels/scheduling-panel.js(new) — render / mutate / save logic. Every chip click, time edit, and row add/remove marks the node dirty and schedules a debounced POST of the fullNodeSettingspayload (read from a per-node cache so co-existing fields likeOnly4K/EncodingOverridesaren't clobbered). Master-clock display: fetch once on panel open, advance from the browser's monotonic clock — master's reported time is parsed as if it were UTC and formatted as UTC, so the displayed string reflects the master's wall clock no matter what timezone the browser is in. Ticker is stopped when the settings modal closes.GET /api/cluster-admin/master-time(new) — returns{ timeZoneId, displayName, utcOffsetMinutes, localTimeIso, isUtc }so the panel can show a live clock and surface a "UTC" warning badge when the server is running with no real timezone configured.POST /api/cluster-admin/node-settingsvalidation — rejects schedules with emptyDayslists, out-of-rangeDayOfWeekvalues, orHH:mmstrings that don't parse to0–23 : 0–59. Bad config doesn't make it to disk where it would silently never match.- Dashboard "Off-schedule" badge — purple-themed warning chip rendered on both self-card and worker-card paths in
cluster-dashboard.js, hover for "New jobs will not be dispatched until the next allowed window."
Weekday chip styling
scheduling-day-chips— purple-fill active state matching the existing site purple palette (var(--bs-purple, #6f42c1)). Used by the new scheduling rows and updated to the same purple tone wherever the "selected weekday" pattern surfaces.
Node warm-up on cluster join
10-second grace before first dispatch
ClusterService.IsNodeWarmedUp—DateTime.UtcNow - node.RegisteredAt >= 10s. Wired into the same three candidate-pool filters as the schedule gate. Without it, the first dispatch to a freshly-registered node could hang on the heartbeat pre-check insideDispatchToNodeAsynclong enough to leave items orphaned inProcessinguntil the stuck-item watchdog rescued them five minutes later.ClusterDiscoveryServicestampsRegisteredAtmaster-side — on first join the master's wall clock wins; on re-registration the existingRegisteredAtis preserved. The payload's serialized value is ignored — only the master's clock matters for the warm-up gate.
Encoder fixes
Intel iGPU low-power → normal-mode fallback
TranscodingServicecalibration loop — when every pass in low-power mode failed before producing a measurable bitrate (e.g. iGPUs with no HEVC LP entrypoint — common on pre-Tiger Lake Intel and on Proxmox LXC where GuC firmware isn't loaded), the loop previously logged "Calibration complete after N passes. Using QP X" and returned a bogus QP that ffmpeg then refused to honor on the real encode. NowlowPowerfailure falls through to normal mode (continue); a normal-mode failure returns(-1, false)to signal "VAAPI cannot encode this file" so the retry chain skips conservative-HW (a no-op for VAAPI) and goes straight to software.
Finalize failed: Could not find file …
HandleEncodeCompletionnotification path — the post-encode notification was readingnew FileInfo(workItem.Path).LengthafterHandleOutputPlacementhad already deletedworkItem.Path(which happens whenDeleteOriginalFile=trueand the source/output extensions differ, e.g..mp4 → .mkv). TheFileNotFoundExceptionsurfaced as "Finalize failed: Could not find file …" on a perfectly successful encode. Now passesworkItem.OutputSizeinstead, which is set on the completion path and survives the source-file delete.
Files Changed
Per-node scheduling
Snacks/Services/ScheduleEvaluator.cs(new) — pure window evaluationSnacks/Models/NodeSettings.cs—Schedule: List<ScheduleWindow>?+ newScheduleWindowrecordSnacks/Models/ClusterNode.cs—OffScheduletransient flagSnacks/Services/ClusterService.cs— dispatch-pool filters;RefreshOffScheduleFlags; local-encoding gate wiring;_masterOffScheduleedge trackingSnacks/Services/TranscodingService.cs—SetLocalScheduleGate;WakeFromSchedule;ProcessQueueAsynchonors the gateSnacks/Controllers/ClusterAdminController.cs— schedule validation inSaveNodeSettings;GET /master-time;selfOffScheduleon the cluster statusSnacks/Controllers/ClusterController.cs— stampsOffScheduleon the self-card inGetStatusSnacks/Views/Shared/_SchedulingSettings.cshtml(new) — Scheduling tab partialSnacks/Views/Shared/_AppModals.cshtml— Scheduling tab nav + paneSnacks/wwwroot/js/settings/panels/scheduling-panel.js(new) — render/save/clock logicSnacks/wwwroot/js/main.js— wiresinitSchedulingPanel/loadSchedulingPanelSnacks/wwwroot/js/api.js—clusterApi.getMasterTimeSnacks/wwwroot/js/cluster/cluster-dashboard.js— "Off-schedule" badge on self + worker cardsSnacks/wwwroot/css/site.css—.scheduling-day-chips,.scheduling-node-section,.scheduling-window-row; purple weekday chip paletteSnacks.Tests/Cluster/ScheduleEvaluatorTests.cs(new) — 17 tests pinning the evaluation rules
Node warm-up
Snacks/Services/ClusterService.cs—IsNodeWarmedUp+NodeWarmupGrace = 10sfilterSnacks/Services/ClusterDiscoveryService.cs— master stampsRegisteredAton first join
Encoder fixes
Snacks/Services/TranscodingService.cs— LP-mode → normal-mode → software fallback for iGPUs without HEVC LP entrypointSnacks/Services/TranscodingService.cs— notification readsworkItem.OutputSizeinstead ofFileInfo(workItem.Path).Length
Version bumps
Snacks/Controllers/HomeController.cs— health endpoint versionSnacks/Services/ClusterDiscoveryService.cs—ClusterVersionprotocol bump to 2.9.0Snacks/Views/Shared/_Layout.cshtml— footer versionREADME.md— badge + footer; new Discord chat buttonbuild-and-export.bat— Docker tag versionelectron-app/package.json/package-lock.json
Full documentation: README.md