Snacks v2.20.1
Automated Media Library Transcoder
A security release, recommended for every install and essential for anyone who reaches Snacks from outside their own network. Snacks ships with sign-in turned off, so an instance that was port-forwarded or put behind a reverse proxy without a password handed anonymous visitors full control. Without a configured password, Snacks now only answers local-network clients. FFmpeg moves to builds that fix PixelSmash (CVE-2026-8461), a heap overflow triggered just by probing a crafted video, in the Docker image and both desktop apps. Every place Snacks could decode Brotli data (CVE-2026-111497) is switched off. File names, settings, and imported presets can no longer add their own FFmpeg options, and names shown in the UI can no longer run script. Sign-in gains attempt limiting and a current-password check, and the desktop app moves to Electron 43.8.3.
Upgrade notes
- Remote access without sign-in is refused. Requests from public addresses get
403while no password is configured. Enable sign-in under Settings → System → Security before exposing Snacks. If an upstream proxy (Authelia, Authentik, …) already authenticates every user, setSNACKS_ALLOW_REMOTE_WITHOUT_AUTH=true. To treat extra address ranges as local, list them inSNACKS_LOCAL_NETWORKS(for example203.0.113.0/24, 2001:db8:1::/48). - Remote dashboards with sign-in off (Homarr, Homepage, Tdarr-style widgets on another network) need an API key, or the embed token for
/iframe/*. - Changing the password signs out every session. While sign-in is on, changing the username or password, or turning sign-in off, asks for the current password.
- Ten failed sign-ins from one client within five minutes pause further attempts from that client until the window passes. Successful sign-ins never count.
- Library Health → Delete only removes files the page lists.
- Advanced video profiles that spell a reserved option another way (
-filter,-filter:v:0,-c:v:0,-pass:v) or use FFmpeg's-/optionfile form are now rejected by the validator. No shipped template uses them. - Building from source: self-contained desktop builds need a .NET 10 SDK that bundles runtime 10.0.13 or newer. On macOS, build the bundled FFmpeg with
electron-app/scripts/build-ffmpeg-mac.shbefore./build-mac.sh;build-mac.shrefuses an FFmpeg older than 8.1.2 (or 7.1.5) or one withoutzscaleandlibvorbis. On Windows, stage gyan.dev'sffmpeg-8.1.2-full_build(or a newer 8.1.x) inelectron-app\ffmpeg;build-installer.batandbuild-electron.batapply the same checks, and name git-snapshot builds (whose version can't be compared) in the error.
Remote access requires sign-in
AuthMiddleware previously let every request through whenever sign-in was off. It now does so only for local clients, decided by ClientNetwork (new):
| Counts as local | Notes |
|---|---|
Loopback, RFC1918 (10/8, 172.16/12, 192.168/16), CGNAT / Tailscale (100.64/10), link-local, IPv6 unique-local (fc00::/7)
| IPv4-mapped IPv6 (::ffff:a.b.c.d) is normalized first
|
IPv6 prefixes (/64 or narrower) assigned to the machine's own interfaces
| Dual-stack home networks give clients public IPv6 addresses, and browsers prefer IPv6 when a hostname resolves both ways. Rescanned once a minute. IPv4 subnets are not included because cloud hosts often sit in large shared public subnets. On a VPS whose provider shares one /64 across customers, those neighbors count as local — enable sign-in there
|
Ranges listed in SNACKS_LOCAL_NETWORKS
| Comma, semicolon, or space separated; bare addresses and CIDR ranges; host bits are masked off |
Forwarding headers — X-Forwarded-For, Forwarded, X-Real-IP, CF-Connecting-IP, True-Client-IP — are only ever used to make a request less trusted: if any address in them is public (or unparseable), the request is treated as remote. A forged header can only lock its sender out, so no trusted-proxy list is needed, and an internet client relayed by a LAN reverse proxy is recognized. A proxy that sends no forwarding headers at all still looks local; Caddy, Traefik, and Nginx Proxy Manager send them by default, plain Nginx needs proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;.
Still reachable from anywhere: /api/health, /metrics, /openapi/v1.json, the sign-in page, static assets, secret-authenticated cluster RPC (/api/cluster/*), any /api/* call with a valid API key, and /iframe/* with the embed token. Everything else gets a plain-text 403 explaining how to enable sign-in; the server logs the refusal at most once a minute.
Static-file allowlist no longer matches by extension
The sign-in check treated any non-/api/ path containing a . as a static file. Real static files are served before the check runs, so that rule only ever matched routes — including the default MVC route's {id} segment, which let a dotted path reach the main page without signing in. Assets are now allowlisted by folder (/lib/, /css/, /js/, /img/, /images/, /webfonts/, /docs/, /favicon), and the published API contract by its exact path.
Sign-in hardening
- Attempt limiting —
LoginThrottle(new) counts failed sign-ins per client and refuses further attempts for the rest of a five-minute window after ten failures; the sign-in page shows how long to wait and the response carriesRetry-After. Behind a LAN reverse proxy the client is identified by the proxy-appended (right-most)X-Forwarded-Forentry, so one attacker can't lock out everyone else. - Current password —
POST /api/auth/configtakescurrentPassword(SaveAuthRequest.CurrentPassword, new). While sign-in is active, a save that changes the username or password or turns sign-in off is refused unless it matches, so a borrowed session or an API key alone can't take over the account. Wrong guesses count toward the same throttle. The Security panel shows a Current password field while a password is set. - Sessions end on password change —
AuthService.UpdateConfigrotates the session secret whenever a new password is saved (it already did when sign-in was turned off), invalidating every outstanding cookie. - Secure cookie behind TLS proxies — the session cookie is marked
SecurewhenX-Forwarded-Protoishttps, not only when Kestrel itself sees HTTPS.
FFmpeg: PixelSmash (CVE-2026-8461)
A heap out-of-bounds write in FFmpeg's MagicYUV decoder fires while a crafted AVI / MKV / MOV is merely probed — and Snacks probes every file its scans find, usually files fetched by Sonarr, Radarr, or Lidarr.
| Build | 2.20.0 | 2.20.1 |
|---|---|---|
| Docker | jellyfin-ffmpeg7 7.1.3-5
| jellyfin-ffmpeg7 7.1.4-3 — upstream FFmpeg 7.1.4 plus Jellyfin's backport of the MagicYUV fixes (0094-backport-a-fix-from-upstream-for-magicyuv-decoder.patch). Same 7.1 line.
|
| macOS app | FFmpeg 8.1 | FFmpeg 8.1.3, built by the new electron-app/scripts/build-ffmpeg-mac.sh with Homebrew ffmpeg@8's configure options plus libzimg and libvorbis, bundled with x265 4.3, SVT-AV1 4.2, LAME 4.0, libvpx 1.17, dav1d 1.5.4
|
| Windows app | gyan.dev git snapshot 2026-04-09-git-d3d0b7a5ee
| gyan.dev 8.1.2 full_build — the first 8.1 release with the MagicYUV fix
|
Both updates were compared against the 2.20.0 builds on a synthetic corpus run through Snacks' own command shapes:
- Docker — outputs are bit-identical apart from the
Lavfversion string in container metadata, and ffprobe output, crop detection, bitrate sampling, health verification, and subtitle extraction are unchanged. Hardware encoders (VAAPI, QSV, NVENC, AMF) could not be exercised; upstream FFmpeg made no functional changes to them between 7.1.3 and 7.1.4, and Jellyfin's VAAPI / QSV patches are unchanged. - macOS — everything Snacks reads or decides on is unchanged: ffprobe output, crop detection, bitrate sampling, the VideoToolbox encoder probes, and VideoToolbox encodes (stream layout, tags,
hvc1, bitrate). The updated x265, SVT-AV1, and LAME produce different output bytes for the same settings — x265 bitrates within ±0.1%, MP3 bitrates and durations identical — and MP3 files now reportLAME4.0as their encoder. - macOS Library Health — FFmpeg 8.1 logged "PPS changed between slices" when verification seeked into some multi-slice HEVC files, which Snacks counted as a decode failure. FFmpeg 8.1.3 decodes them the same way without the message, so those healthy files no longer show as verify-failed.
The macOS FFmpeg build
Homebrew's FFmpeg is built without libzimg and libvorbis, so the Mac app could never tonemap HDR to SDR (the zscale filter) or encode Ogg Vorbis music. build-ffmpeg-mac.sh (new) installs ffmpeg@8's Homebrew dependencies plus zimg and libvorbis, verifies the FFmpeg 8.1.3 source tarball's SHA-256, builds it with ffmpeg@8's configure options plus --enable-libzimg --enable-libvorbis (outside the repository, since FFmpeg's build can't handle a path with spaces), and bundles the result into electron-app/ffmpeg. Run it once per FFmpeg update.
Compared with Homebrew's ffmpeg@8 8.1.3 across the same test suite, the only differences are the additions: zscale, the libvorbis encoder and decoder, and Vorbis as the Matroska / Ogg / NUT muxers' default audio codec (Snacks always names its audio codec). HDR tonemapping, VideoToolbox encodes with tonemapping, and Ogg Vorbis encodes now succeed; every other test produced the same results as the Homebrew build, including its run-to-run encoder variation. The tonemapped frames match the Docker build's (SSIM 0.997). The yadif_videotoolbox filter is left out unless Xcode's Metal Toolchain is installed; Snacks doesn't use it.
build-mac.sh now reads the staged FFmpeg's version and stops if it predates the fix (8.1.2 on the 8.1 line, 7.1.5 on 7.1), warning on any major version other than 8; SNACKS_ALLOW_UNPATCHED_FFMPEG=1 overrides for an emergency build. It also stops if the staged FFmpeg lacks zscale or libvorbis, and points to build-ffmpeg-mac.sh for both.
The Windows FFmpeg build
The Windows app moves from a gyan.dev master snapshot taken before the fix to gyan.dev's 8.1.2 release build. Its advertised encoders, decoders, filters, and hardware accelerations match the snapshot's apart from two master-only additions Snacks doesn't use (the pdv encoder and the v360_vulkan filter); AMF, NVENC, QSV (libvpl), and D3D11VA are all present. gyan.dev's ffmpeg-release-full link now serves FFmpeg 9, which the desktop build hasn't been validated against, so the build scripts point to the 8.1 release archive instead.
scripts/validate-ffmpeg-version.mjs (new) is the Windows counterpart of build-mac.sh's check, run by build-installer.bat and build-electron.bat: it refuses an FFmpeg older than 8.1.2 (or 7.1.5), refuses a git snapshot whose version can't be compared, warns on a major version other than 8, and requires zscale and libvorbis. SNACKS_ALLOW_UNPATCHED_FFMPEG=1 overrides the version check.
FFmpeg arguments from file names and settings
File paths are always single arguments
Bitrate sampling (MeasureVideoBitrateAsync), VAAPI quality calibration (RunTestEncodeAsync), crop detection, and music encodes built their commands as strings with the file path in double quotes, then had them split back into arguments. A path containing a " could therefore end its argument early and add FFmpeg options of its own. All four now pass the path as one literal argument (FfmpegArgumentList.Add("-i", path)), and MusicEncoderArgs.Build returns an FfmpegArgumentList instead of a string. The main encode path already did this.
For ordinary file names — spaces, parentheses, brackets, apostrophes, &, $, back-ticks, Unicode — the argument vectors are byte-for-byte identical to 2.20.0 across every combination of hardware init flags, filters, and music options (2,850 cases compared). The only differences are paths containing " and music paths containing \.
Windows music fix: the string tokenizer treated \ as an escape character, so every Windows music path (C:\Music\…) reached FFmpeg with its separators stripped and the encode failed. Music encodes now work on Windows.
Encoder, preset, and level are plain values
Encoder, FfmpegQualityPreset, and VideoLevel come from settings, imported presets, per-request options, folder overrides, and cluster job metadata, and were placed into the same split-on-whitespace strings. They must now be a single plain token (letters, digits, _, ., -, not starting with -; surrounding whitespace is trimmed as before):
TranscodingService.RequirePlainEncoder(new) fails the job with "'…' is not a valid FFmpeg encoder name" before the encoder probe runs.- An invalid preset falls back to
mediumand an invalid level is omitted, each with a job-log line. FfmpegArgumentList.IsPlainValue(new) is the shared check. Every value FFmpeg accepts for these options passes.
Advanced video: reserved options in any spelling
AdvancedVideoValidator reserves options that control command structure, but compared them exactly, so -filter (no stream specifier), -filter:v:0, -filter_script:v, -c:v:0, and -pass:v slipped past entries for -vf, -filter:v, -c:v, and -pass. IsReservedToken (new) also checks the option's base name before the first :, adds -filter, and rejects FFmpeg 7+'s -/option form, which reads the option's value from a file. Custom option values get the same check.
Brotli (CVE-2026-111497)
An out-of-bounds write in Google's Brotli decoder (1.0.3–1.2.0) affects the copy built into .NET 10 and Ubuntu's libbrotli1; neither vendor has shipped a fixed build yet. Snacks now never hands data to a Brotli decoder:
- Luna connector — the only HTTP client with automatic decompression used
DecompressionMethods.All. It now decompresses gzip and deflate only, sobris neither requested nor decoded. - Request bodies — a request carrying any
Content-Encodingother thanidentityis refused with415before routing. Snacks never decompressed request bodies, so no working client is affected; this keeps any future middleware from starting to. - Desktop window — the Electron window may only load from its own backend (
http://localhost:<port>, including the SignalR WebSocket). Chromium's response decoders never see third-party content; external links still open in the system browser. BrotliExposureTests(new) fails the build ifDecompressionMethods.All/.Brotli,BrotliStream,BrotliDecoder, or request decompression appears in the application code.
The vulnerable library code remains present but unused inside the .NET runtime and the image's libbrotli1 (a FreeType dependency); a later image rebuild picks up the vendors' fixes once they ship.
Web UI: names render as text
File, folder, preset, template, and cluster node names come from media, shared preset files, and remote nodes. Several places inserted them into the page as HTML:
showToast(site.js) now sets the message as text. None of its 131 callers passed markup.- Confirm dialogs that interpolate a preset label, preset name, template name, or imported file name (
presets.js,advanced-video.js) escape it. - The cluster dashboard escapes node IDs in
data-node-idattributes.
Cluster
ClusterAuthFilterandLocalNetworkOnlyFilterare now authorization filters, so a caller without the shared secret is refused before its request body is read or model-validated.GET /api/cluster/integrations(credential hand-out to workers) also refuses requests whose forwarding headers name a public client, so a reverse proxy in front of the master no longer makes internet callers look like LAN workers.- Upload names — a chunked upload named
_shared.jsonor_metadata.json(any case) is stored asinput_shared.json/input_metadata.json(ClusterController.ToUploadFileName, new), so it can't replace the job's own control files. Names are compared in the form Windows resolves them to, so on a Windows worker a trailing dot or space (_shared.json.), a stream suffix (_shared.json::$DATA), or an 8.3 short name (_SHARE~1.JSO) is also stored under the prefixed name. Media names, including ones that start with an underscore, are unchanged.
Encoding fixes
These failed before 2.20.1 on every FFmpeg version; each fix changes only the command for the case that used to fail.
- Opus music with a VBR quality setting —
MusicEncoderArgs.BuildturnedVbrQualityinto-q:a, which FFmpeg's libopus encoder rejects, so every Opus encode with it set failed. Opus has no quality scale and is always VBR, so it now uses the configured bitrate as its VBR target; MP3, Vorbis, and AAC keep-q:a. - MP4 timed-text subtitles into MKV — Matroska has no codec ID for MP4's
mov_text, so keeping one of those tracks in an MKV output aborted the mux ("Subtitle codec mov_text is not supported"). Both subtitle paths — the language-filtered pass-through (FfprobeService.MapSub) and the OCR/sidecar mux — now convert just those tracks to SubRip (FfprobeService.NeedsSrtForMatroska, new). Every other subtitle codec is still stream-copied. - 8-bit profiles on 10/12-bit sources — libx264 rejects
baseline/main/highand libx265 rejectsmain/mainstillpicturewhen the source has more than 8 bits per sample ("high profile doesn't support a bit depth of 10"). The simple video path now drops an 8-bit-only profile for such a source, with a job-log line, and the encoder chooses High 10 / Main 10 — the same output Snacks produces when no profile is configured. A tonemapped encode ends in 8-bit and keeps its profile; advanced profiles are untouched, since they can set their own pixel format (TranscodingService.IsEightBitOnlyProfile/IsHighBitDepthSource, new). - macOS: HDR→SDR tonemapping and Ogg Vorbis music — the Mac app's FFmpeg had neither the
zscalefilter (libzimg) nor thelibvorbisencoder, so every tonemapped encode and every Ogg Vorbis music encode failed. The Mac build now includes both (see the FFmpeg section). The unusedVideoFilterBuilder.TonemapSwChainFallbackis removed: it could never tonemap correctly, sincecolorspacecan't linearize PQ or HLG.
Library Health
POST /api/library/health/delete checked only that the path was inside the library. It now also requires the file to be listed on the Library Health page under any category (MediaFileRepository.IsHealthFlaggedAsync, new); other paths get 400 "File is not flagged in Library Health". Delete all was already driven by the health list.
Desktop app
- Electron 43.8.3 (from 43.1.1) resolves twelve Electron security advisories; build tooling dependencies (
undici,fast-uri,http-cache-semantics) were updated within their ranges, andnpm auditis clean. - In-window navigation is checked against the parsed backend origin instead of a string prefix, so a URL such as
http://localhost:6767@other.host/opens in the system browser like any other external link. - .NET runtime 10.0.13 — self-contained builds ship whatever runtime the building SDK bundles.
Snacks.csprojgainsMinimumSelfContainedRuntimeVersionand a target that fails a self-contained build below it, so desktop builds include the October 2026 ASP.NET Core security update. The Docker image'saspnet:10.0base already resolves to 10.0.13.
Response headers
Every response now carries X-Content-Type-Options: nosniff and Referrer-Policy: same-origin. X-Frame-Options is deliberately not set: dashboards such as Organizr embed the full UI in a frame, and /iframe/* keeps its own frame-ancestors allowlist.
Documentation
/docs/index.html— the authentication section explains remote access (what counts as local, forwarding headers,SNACKS_LOCAL_NETWORKS,SNACKS_ALLOW_REMOTE_WITHOUT_AUTH), the sign-in throttle, and the current-password requirement; the API reference documentscurrentPasswordonPOST /api/auth/configand the Library Health delete scope.- README — the Docker quick start notes that remote access requires sign-in.
docs/BUILDING.mdand the in-app build card — macOS builds installffmpeg@8and copy its binaries.docs/BUILDING.md— Windows builds stage gyan.dev's FFmpeg 8.1.xfull_buildinstead offfmpeg-release-full(now FFmpeg 9), and need a .NET SDK that bundles runtime 10.0.13.
Tests
RemoteAccessGateTests(new) — loopback, RFC1918, CGNAT, link-local, unique-local, and IPv4-mapped peers are local, public IPv4 / IPv6 peers are remote; a LAN proxy relaying a public client is remote for every supported header and address form (ports, brackets, quotedForwarded,unknown); configured ranges extend the local set; the rate-limit key uses the proxy-appended client and ignores forged headers from public peers; with sign-in off, LAN clients pass, public and proxied-public clients get403, infrastructure routes stay reachable, and API keys and embed tokens admit public clients only on their own routes.LoginThrottleTests(new) — the budget, per-client isolation, and window expiry.SessionRotationTests(new) — a password change invalidates existing sessions; a save without a new password keeps them.ArgumentInjectionTests(new) — plain-value acceptance and rejection, refusal of an unrecognized encoder thatGetEncoderpasses through, music paths containing quotes staying single arguments, reserved option spellings rejected and ordinary encoder options accepted, and reserved upload names, including their Windows aliases (on Windows, an aliased upload is written next to a real_shared.jsonand the control file must be unchanged).HealthDeleteScopeTests(new) — only rows the health page lists count as flagged.BrotliExposureTests(new) — no Brotli decode path in application code.AuthMiddlewareTests— dotted non-asset paths require the session; asset folders and/openapi/v1.jsonstay public.MusicEncoderArgsTests(new) — Opus ignoresVbrQualityand targets the bitrate; MP3, Vorbis, and AAC keep-q:a; withoutVbrQualityevery lossy encoder uses the bitrate.SubtitleMappingTests— Matroska copy flags are unchanged when no track needs conversion, and amov_texttrack becomes-c:s:N srt.RateControlAndScaleTests— the 8-bit-only profile set and high-bit-depth detection by pixel format (yuv420p10le,p010le, 12- and 16-bit) and profile name.
Files Changed
Remote access and sign-in
Snacks/Services/ClientNetwork.cs(new) — local-network classification, forwarding-header parsing, interface IPv6 prefixes,SNACKS_LOCAL_NETWORKS, rate-limit identitySnacks/Services/LoginThrottle.cs(new) — failed sign-in counterSnacks/Services/AuthMiddleware.cs— remote gate, folder-based asset allowlist, embed-token and API-key admission with sign-in off, throttled rejection logSnacks/Services/AuthService.cs— session secret rotates on password changeSnacks/Controllers/AuthController.cs— throttle andSecurecookie behind TLS proxiesSnacks/Controllers/AuthApiController.cs,Snacks/Models/Requests/SaveAuthRequest.cs— current-password requirementSnacks/Views/Shared/_SecuritySettings.cshtml,Snacks/wwwroot/js/settings/panels/auth-panel.js,Snacks/wwwroot/js/api.js— Current password field
FFmpeg
Snacks/Dockerfile—jellyfin-ffmpeg7=7.1.4-3*electron-app/scripts/build-ffmpeg-mac.sh(new) — builds and stages the macOS FFmpegbuild-mac.sh— FFmpeg version andzscale/libvorbischecks; points tobuild-ffmpeg-mac.shscripts/validate-ffmpeg-version.mjs(new) — the same checks for Windows buildsbuild-installer.bat,build-electron.bat— runvalidate-ffmpeg-version.mjs; point to gyan.dev's 8.1 release archiverun-electron-dev.bat— points to gyan.dev's 8.1 release archiveSnacks/Services/TranscodingService.cs— literal path arguments for bitrate sampling, calibration, and crop detection;RequirePlainEncoder,IsEightBitOnlyProfile,IsHighBitDepthSource(new); preset and level validation; 8-bit-only profiles dropped for high-bit-depth sources;mov_text→ SubRip in the sidecar muxSnacks/Services/MusicEncoderArgs.cs— returnsFfmpegArgumentList; Opus ignoresVbrQualitySnacks/Models/MusicEncoderOptions.cs—VbrQualitydocuments the Opus exceptionSnacks/Services/FfprobeService.cs—NeedsSrtForMatroska(new);MapSubconvertsmov_textfor MatroskaSnacks/Services/VideoFilterBuilder.cs—TonemapSwChainFallbackremoved; the zscale requirement documentedSnacks/Services/FfmpegArgumentList.cs—IsPlainValue(new)Snacks/Services/AdvancedVideoValidator.cs—IsReservedToken(new),-filterreserved
Brotli, headers, and request handling
Snacks/Program.cs— Luna client without Brotli,415for encoded request bodies,nosniff/Referrer-Policy,LoginThrottleregistration
Web UI
Snacks/wwwroot/js/site.js,Snacks/wwwroot/js/settings/presets.js,Snacks/wwwroot/js/settings/advanced-video.js,Snacks/wwwroot/js/cluster/cluster-dashboard.js
Cluster and library
Snacks/Services/ClusterAuthMiddleware.cs,Snacks/Services/LocalNetworkOnlyFilter.cs— authorization filters; forwarding-header checkSnacks/Controllers/ClusterController.cs—ToUploadFileName(new), including Windows name aliasesSnacks/Controllers/LibraryController.cs,Snacks/Data/MediaFileRepository.cs—IsHealthFlaggedAsync(new)
Desktop app
electron-app/main.js— window restricted to the backend origin; parsed-origin navigation checkelectron-app/package.json/package-lock.json— Electron 43.8.3 and tooling updatesSnacks/Snacks.csproj—MinimumSelfContainedRuntimeVersionandRequirePatchedSelfContainedRuntime
Documentation
Snacks/wwwroot/docs/index.html,README.md,docs/BUILDING.md
E2E harness
e2e/lib.sh— scenarios start instances inside$(...), so the PIDs never reached the EXIT trap and every run left its instances running (listening on all interfaces withSNACKS_ALLOW_ALL_PATHS); PIDs are now also recorded in a temp file the trap reads
Tests
Snacks.Tests/Security/RemoteAccessGateTests.cs,LoginThrottleTests.cs,ArgumentInjectionTests.cs,HealthDeleteScopeTests.cs,BrotliExposureTests.cs,Snacks.Tests/Auth/SessionRotationTests.cs,Snacks.Tests/Audio/MusicEncoderArgsTests.cs(all new)Snacks.Tests/Security/AuthMiddlewareTests.cs,Snacks.Tests/Subtitles/SubtitleMappingTests.cs,Snacks.Tests/Video/RateControlAndScaleTests.cs,Snacks.Tests/Pipeline/FullCommandScenarioTests.cs
Version bumps
Snacks/Snacks.csproj—<Version>2.20.1</Version>(single source of truth)electron-app/package.json/package-lock.json,build-and-export.bat,Snacks.Tests/Settings/AppVersionTests.cs,Snacks/wwwroot/docs/index.html— synchronized viasync-version.mjs; the docs page's updated date moves to 2026-10-24
Full documentation: README.md · /docs/index.html on a running instance