Snacks v2.12.0
Automated Media Library Transcoder
A feature release focused on track ordering, hearing-impaired-subtitle removal, and Intel QSV on Linux. Audio- and subtitle-language keep-lists are now ordered preferences — the chips you drag to the top of the list become track 0 in the output and (optionally) auto-play in players that honour the default-disposition flag, instead of the muxer keeping whatever source order the file happened to ship with. A new Exclude hearing-impaired (SDH/CC) subtitles toggle drops tracks tagged via ffprobe's disposition.hearing_impaired and tracks whose title looks like "English [SDH]", "English (CC)", "HI", or "HoH" — the title fallback is what catches Blu-ray bitmap rips, where the disposition flag is rarely set. Linux Intel hardware acceleration now probes QSV via oneVPL before falling back to VAAPI, picking up the Intel-native encode path that ships with Jellyfin-FFmpeg in the Docker image; the existing VAAPI pipeline stays as the fallback for older drivers or builds without QSV. Three operability fixes: the Retry button on Failed cards now actually re-queues the file immediately (it previously just reset the DB row, leaving the user to wait for the next AutoScan or guess that the card vanishing meant retry succeeded); file-placement retries route their messages into the per-job log with the underlying HResult decoded (sharing-violation, lock-violation, access-denied), so a multi-minute backoff against an external lock no longer looks like a silent hang followed by a generic "Error handling output placement" failure; and the PUID/PGID entrypoint no longer recursively chowns the user's /app/work/uploads media library, fixing the startup hang that broke v2.11's PUID support on NFS- and SMB-mounted shares.
Ordered audio / subtitle preferences
Keep-list order now determines output stream order
FfprobeService.MapSub— whenlanguagesToKeepis non-empty, subtitle streams are now sorted by their position in the keep-list (primary key) then by source order within a single-language bucket (secondary key). Pre-v2.12 the muxer mapped streams in source order, so a file with English track 1 / French track 2 would emit them in that order even when the user's preference was[fr, en]. Internal helperPreferenceIndexresolves each stream's language viaLanguageMatcher.ToTwoLetterwith a title-inference fallback so a track whoseTags.Languageis missing but whose title is "Français" still sorts by the French preference index. Unresolvable languages sort last viaint.MaxValue(defensive — the upstream filter should have already dropped them).TranscodingService.BuildFfmpegCommand(sidecar/OCR-mux subtitle path) — the same reorder is applied to_ffprobeService.SelectSidecarStreamsresults before the-maplines are emitted, so the OCR-mux path (which doesn't go throughMapSub) gets the same output ordering. OCR'd SRTs appended after source subs follow their original index order — the user-facing priority comes from the source subs first. New private helperSidecarPreferenceIndexmirrors theMapSubresolver but takes a flatlangstring becauseSidecarSpecdoesn't carry the fullStreamshape.- Audio reorder is implicit —
MapAudioalready iterates_languageBucketsin keep-list order, so the order fix there is "do nothing extra and let the existing bucket loop produce the right output." Tests pin the behaviour against future refactors.
Auto-set default track for the top-priority language
EncoderOptions.AutoSetDefaultTrack(new) — single toggle that flags both the first kept audio output and the first kept subtitle output as the container's default. Defaultfalseso an upgrading user's existing files don't get re-muxed for a disposition flip alone. Combined with the reorder this means the top-priority audio + subtitle auto-play in players that honourdefault=1(Plex, VLC, mpv, Infuse).FfprobeService.MapAudioautoSetDefaultparameter — appends-disposition:a:0 defaultand-disposition:a:N 0forN=1..outIndex-1. Emitted after the meta block because-dispositionresets all disposition flags on the target stream — we want it to win against any staledefault=1carried in via-c:a copy.FfprobeService.MapSubautoSetDefaultparameter — symmetric counterpart, emits-disposition:s:0 default+-disposition:s:N 0. The MuxOnly subtitle branch inTranscodingService.BuildFfmpegCommand(which builds its ownmaps/codecs/metarather than callingMapSub) also computes the disposition string locally so OCR-mux output gets the same default flag.- Setting is gated on doing work — the auto-default flag is only emitted when
doAudioWork/doSubtitleWorkis true. Toggling the global flag on a folder whose audio plan is "copy unchanged" doesn't suddenly start re-muxing every file in the library — work-gating decides; disposition is a side effect.
Drag-to-reorder chip inputs in settings
Snacks/wwwroot/js/settings/chip-input.js— when a.chip-inputcarriesdata-reorderable="true", each chip is rendered with afa-grip-verticalhandle, madedraggable="true", and giventabIndex=0.wireChipReorderhandlesdragstart/dragover/dragleave/drop, computing the drop slot from the cursor's X position relative to the candidate chip's midpoint. Keyboard reorder isAlt+ArrowLeft/Alt+ArrowRighton the focused chip — necessary because pure drag-and-drop fails accessibility audits. Any reorder fires achangeevent on the root so the settings form picks it up via the existing dirty-tracking pipeline.Snacks/wwwroot/css/site.css— chip-grip, dragging, and drop-indicator (chip-drop-before/chip-drop-afterbox-shadow slot markers) styles. Focus-visible outline so keyboard users see which chip they're acting on.Snacks/Views/Shared/_AudioSettings.cshtml/_SubtitleSettings.cshtml— opt the language chip-inputs into reorderable mode viadata-reorderable="true". Helper text updated to mention drag-to-reorder.Snacks/wwwroot/js/settings/encoder-form.js— wireAutoSetDefaultTrackandExcludeSdhSubtitlesinto the read/write loop so the form actually persists them.
Reorder triggers a re-mux on already-acceptable files
TranscodingService.WouldReorder(new) — given the user's keep-list and the source stream-language sequence, returnstruewhen the kept-language order would change after sorting by preference. Canonicalises both sides to two-letter codes (with raw fallback) and dedupes the source side so equal-language tracks don't poison the comparison. Used byHasAudioWorkandHasSubtitleWorkto trigger work on files that already contain every kept language but in the wrong source order — without this, a MuxOnly user toggling preference order would see nothing happen on already-fine files.
Hearing-impaired (SDH/CC) subtitle removal
Disposition + title-based detection
Dispositionmodel (new inProbeResult.cs) — captures ffprobe's per-stream disposition flags. OnlyHearingImpaired(andDefaultas a future tiebreaker) are consulted today, but every flag is captured so future features can use them without a probe-schema change.FfprobeService.IsHearingImpaired— returnstruewhen the stream'sDisposition.HearingImpaired == 1orLanguageMatcher.IsSdhTitle(title)matches. The disposition flag alone is unreliable: it's set on streamer encodes but rarely on Blu-ray bitmap rips, where the track name "English [SDH]" is the only signal.LanguageMatcher.IsSdhTitle— word-boundary regex matching "SDH", "CC", "HI", "HoH", "Hearing Impaired", "For the Hearing Impaired" (case-insensitive, with separator-flexible matching for the multi-word forms). The narrow tokens (cc,hi,hoh) are matched with\bword boundaries so they don't false-positive on language names like "Chichewa" that happen to contain "hi" as a substring. Tests pin the boundary behaviour against future regex tweaks.
Drop in muxed-subtitle + sidecar + OCR-mux paths
EncoderOptions.ExcludeSdhSubtitles(new) — single setting drives all three subtitle paths. Defaultfalse.FfprobeService.MapSubexcludeSdhparameter — filters the candidate-sub list before language filtering so the SDH track never reacheskeepSubs.FfprobeService.SelectSidecarStreamsexcludeSdhparameter — same filter applied at the sidecar/OCR-mux entry point. Wired throughSubtitleExtractionService.ExtractAsyncandOcrBitmapsForMuxAsync.MuxStreamSummary.SubtitleStreamSummary.Sdh(new field, JSONs) — persisted with the work item soHasSubtitleWorkcan decide mux-pass eligibility from the cached summary without re-running ffprobe.BuildSubtitleStreamSummaries(inTranscodingService) populates it viaFfprobeService.IsHearingImpaired. Pre-v2.12 work-item rows deserialise the missing field asfalse, preserving back-compat.TranscodingService.HasSubtitleWork— short-circuits totruewhenExcludeSdhSubtitlesis on and at least one cached summary hasSdh = true, so toggling SDH-exclude on a previously-acceptable file triggers a re-mux.
Settings UI
Snacks/Views/Shared/_SubtitleSettings.cshtml— adds the "Exclude hearing-impaired (SDH/CC) subtitles" checkbox under the subtitle keep-list.
Linux Intel QSV (oneVPL) hardware acceleration
Detection probes QSV before VAAPI per render node
TranscodingService.DetectHardwareAccelerationAsync(Linux branch) — for eachrenderD*node enumerated byEnumerateRenderNodes, tries the QSV pipeline (-hwaccel qsv -qsv_device {node}) for hevc / h264 / av1 before the existing VAAPI two-driver probe (iHD, i965). When QSV succeeds the device is registered asintelwithDisplayName="Intel QSV", the staticLinuxIntelUsesQsvflag is set, and the VAAPI probe for that node is skipped. On Linux QSV requires the iHD driver — i965 doesn't expose QSV — soLIBVA_DRIVER_NAME=iHDis pinned before probing instead of relying on the host's default. Thetry/finallyalready in place restores the originalLIBVA_DRIVER_NAMEafter detection.TranscodingService.LinuxIntelUsesQsv(new internal static) — set totrueduring Linux detection when QSV probing succeeds. The static helpersGetEncoderandGetInitFlagsread it to pick the QSV variants over VAAPI defaults on Linux Intel jobs. Static because detection is a one-shot at startup and the helpers don't have an instance handle — the alternative would be threading a new field throughEncoderOptions, which is per-dispatch ephemeral state nobody else needs.
Encoder + init-flag selection
TranscodingService.GetEncoderoverload — adds abool linuxIntelQsvparameter; on Linux Intel jobs with the flag on, pickshevc_qsv/h264_qsv/av1_qsvinstead of the VAAPI variants. AMD jobs are unaffected (the QSV flag is Intel-only). The original parameterless overload readsRuntimeInformation.IsOSPlatform(OSPlatform.Windows)andLinuxIntelUsesQsvso the call sites don't change.TranscodingService.GetInitFlags5-arg overload — addslinuxIntelQsvto the existing path-aware overload. Two new Linux-Intel branches:- Hardware-decode capable input:
-hwaccel qsv -hwaccel_output_format qsv -qsv_device {node}— full QSV pipeline on the detected render node. - Software-decoded input:
-init_hw_device vaapi=va:{node} -init_hw_device qsv=hw@va -filter_hw_device hw— the canonical FFmpeg form that derives QSV from a VAAPI base on the same render node, so the encoder finds a QSV context even though decode runs in software.
- Hardware-decode capable input:
ClusterDiscoveryService.BuildAvailableEncoders— on Linux Intel, advertiseshevc_qsv/h264_qsvinstead of the VAAPI encoders whenLinuxIntelUsesQsvis set, so the master scheduler dispatches QSV-aware jobs.
Tests
Snacks.Tests/Video/HardwareEncoderTests.cs— six new theories pinning the QSV path:GetEncoder_intel_on_linux_with_qsv_picks_qsv_variant(hevc/h264/av1)GetEncoder_intel_on_linux_without_qsv_picks_vaapi_variant(regression guard for the existing VAAPI fallback)GetEncoder_linux_qsv_does_not_affect_non_intel(Intel-only — the QSV gate must not widen to AMD or NVIDIA)GetInitFlags_intel_on_linux_with_qsv_uses_qsv_pipeline(hw-decode form, asserts QSV flags and absence ofvaapi)GetInitFlags_intel_on_linux_with_qsv_and_sw_decode_derives_qsv_from_vaapi(sw-decode form, pins thevaapi=va+qsv=hw@va+filter_hw_device hwchain)GetInitFlags_linux_qsv_does_not_affect_amd(regression — AMD stays on VAAPI regardless of the QSV flag)
Retry button actually re-queues
Failed cards bypass the next AutoScan
QueueController.Retry— after the existingRetryFileAsync(which clears the DB failure state and removes the cached_workItemsentry), now also callsAutoScanService.AddSingleFileAsyncso the file goes back into the queue immediately rather than waiting for the next AutoScan tick. Re-add failures (file gone, probe failed, current exclusion rules drop it) returnBadRequestwith the underlying message so the UI can toast it — the DB row is already reset, so the next scan will see the file as Unseen and try again. Bug-fix scope, not a behaviour change: the button promised retry, but only delivered "reset state" — the user had to either trigger Scan Now manually or wait for the periodic scan.AutoScanService.AddSingleFileAsync(new) — re-queues a single file using the current global encoder options merged with any folder-level overrides (the same options recipe the periodic scan uses). Bypasses the "may still be transferring" mtime guard because this is an explicit user action.TranscodingService.RetryFileAsync— now emitsWorkItemRemovedover SignalR when the failed entry is dropped from_workItemsso the UI removes the card immediately. SignalR errors are caught and ignored — the DB state is already reset and the next scan will reconcile.
UI: dedicated retry handler + new SignalR event
Snacks/wwwroot/js/queue/work-item-renderer.js—Failednow renders the same retry+log button group asNoSavings(it was previously folded in withCompleted/Cancelled/Stopped, which only get a log button). Tooltip + comments distinguish the two retry semantics: Failed = file errored out (often a transient lock); NoSavings = encode finished but didn't shrink.Snacks/wwwroot/js/queue/queue-manager.js— addsretry(id, filePath), which optimistically removes the card after the API call returns 200 (the SignalRWorkItemRemovedarrives moments later but the optimistic delete avoids a perceived lag).removeItem(id)is the SignalR handler that drops the card from the in-memory map and DOM. The card-click delegate already routeddata-action="retry"clicks — it now resolves the file path from the in-memory map and dispatchesretry().Snacks/wwwroot/js/core/signalr-client.js/main.js— registersWorkItemRemovedin the known-events list and wires it toqueueManager.removeItem.
File-placement logging surfaces the real failure
Retry messages route into the per-job log with HResult decoded
FileService.FileMoveAsync/FileDeleteAsync— both gain an optionalFunc<string, Task>? onRetrycallback. When supplied, retry messages are routed through that callback (typicallyLogAsync(workItem.Id, ...)) instead ofConsole.WriteLine, so a multi-minute backoff against an external lock (antivirus scan, indexer holding a handle) is visible in the work-item log instead of looking like a silent hang.FileService.RetryAsync— the same callback is threaded through. NewEmitRetryAsynchelper falls back toConsole.WriteLinewhen no callback is supplied or when the callback itself throws (so a busted logger never disables the retry path).FileService.FormatExceptionTag(new) — decodes the low 16 bits ofIOException.HResultto a human-readable tag for the most common Windows file-operation failures:0x0020 → sharing-violation,0x0021 → lock-violation,0x0005 → access-denied. Other HResults are logged with the type name and raw hex so the user can grep them.TranscodingService.HandleOutputPlacement— passesmsg => LogAsync(workItem.Id, msg)into everyFileMoveAsync/FileDeleteAsynccall. The pre-move and pre-delete log lines now include both source and destination paths so the operator can correlate which file operation the retry tag belongs to. The outercatch (Exception ex)now includes the exception type and inner-exception message in the log line so the "Error handling output placement: …" surface distinguishes IOException sharing-violation from access-denied from disk-full.
PUID/PGID entrypoint skips external mounts
Recursive chown no longer descends into uploads
Snacks/entrypoint.sh— whenPUIDis set, the entrypoint previously ranchown -R snacks:snacks /app/work, which on the recommended split-mount layout (/app/work/uploadsbind-mounted to a media library, often NFS or SMB) would walk every file on the share. On a sufficiently large library this hangs startup indefinitely or is killed by the OOM killer, and when the chown dies mid-walk the snacks user never gains ownership of/app/work/.snacks.lock— so the app crashes withUnauthorizedAccessExceptionon the lock open. The fix narrows the chown to Snacks-managed paths: the work-dir root itself (non-recursively, for the lock file), and every immediate child exceptuploadsandoutput, which are the documented external-mount targets indocker-compose.yml/unraid/README.md. The NAS share already enforces its own ownership; the container has no business rewriting it — that was always the point of PUID matching the NAS owner, the chown was overreach.SNACKS_DISABLE_CHOWN=true(new) — opt-out for users whose entire/app/worktree is host-managed and who want zero startup chown. Acceptstrueor1(case-insensitive). Default behaviour (skip uploads/output, chown everything else) covers the documented layouts without needing this knob.
Files Changed
Ordered audio/subs + auto-default + SDH removal
Snacks/Models/EncoderOptions.cs—ExcludeSdhSubtitles,AutoSetDefaultTrack; copied throughWithOverridesSnacks/Models/MuxStreamSummary.cs—SubtitleStreamSummary.SdhSnacks/Models/ProbeResult.cs—Stream.Disposition; newDispositionclassSnacks/Services/FfprobeService.cs—MapAudioautoSetDefault;MapSubexcludeSdh/autoSetDefault+ preference-order reorder;SelectSidecarStreamsexcludeSdh;IsHearingImpaired;PreferenceIndexSnacks/Services/LanguageMatcher.cs—IsSdhTitle+ word-boundary SDH regexSnacks/Services/SubtitleExtractionService.cs—excludeSdhthreaded throughExtractAsync/OcrBitmapsForMuxAsyncSnacks/Services/TranscodingService.cs—WouldReorder,SidecarPreferenceIndex, OCR-mux sidecar reorder, mux-pass subtitle reorder + auto-default disposition, audio auto-default disposition,HasAudioWork/HasSubtitleWorkre-mux trigger on reorder + SDH drop,Sdhpopulated byBuildSubtitleStreamSummariesSnacks/Views/Shared/_AudioSettings.cshtml— reorderable chips, AutoSetDefaultTrack toggleSnacks/Views/Shared/_SubtitleSettings.cshtml— reorderable chips, ExcludeSdhSubtitles toggleSnacks/wwwroot/css/site.css— drag-to-reorder chip stylesSnacks/wwwroot/js/settings/chip-input.js—data-reorderablemode + drag + keyboard reorderSnacks/wwwroot/js/settings/encoder-form.js—AutoSetDefaultTrack/ExcludeSdhSubtitlesread+restoreSnacks.Tests/Audio/AudioPlannerTests.cs— AutoSetDefault on/offSnacks.Tests/Audio/LanguageMatcherTests.cs—IsSdhTitletheorySnacks.Tests/Fixtures/ProbeBuilder.cs— Subtitle builder acceptshearingImpaired/defaultFlag/forcedSnacks.Tests/Subtitles/HasSubtitleWorkTests.cs— SDH-drop and reorder triggersSnacks.Tests/Subtitles/SubtitleMappingTests.cs— preference-order reorder, SDH disposition + title exclusion, AutoSetDefault on subs, sidecar SDH drop
Linux Intel QSV
Snacks/Services/TranscodingService.cs— Linux QSV probe inDetectHardwareAccelerationAsync;LinuxIntelUsesQsv; newGetInitFlags(..., linuxIntelQsv, ...)overload + sw-decode QSV-from-VAAPI form;GetEncoder(..., linuxIntelQsv)overloadSnacks/Services/ClusterDiscoveryService.cs— advertisehevc_qsv/h264_qsvwhenLinuxIntelUsesQsvis setSnacks.Tests/Video/HardwareEncoderTests.cs— six theories pinning QSV/VAAPI selection and init flags
Retry button
Snacks/Controllers/QueueController.cs—Retryre-adds viaAutoScanService.AddSingleFileAsyncSnacks/Services/AutoScanService.cs—AddSingleFileAsyncSnacks/Services/TranscodingService.cs—RetryFileAsyncemitsWorkItemRemovedSnacks/wwwroot/js/core/signalr-client.js—WorkItemRemovedeventSnacks/wwwroot/js/main.js— wireWorkItemRemoved → queueManager.removeItemSnacks/wwwroot/js/queue/queue-manager.js—retry,removeItem, retry-button click routeSnacks/wwwroot/js/queue/work-item-renderer.js— Failed renders retry+log button group
File-placement logging
Snacks/Services/FileService.cs—FileMoveAsync/FileDeleteAsynconRetrycallback;EmitRetryAsync;FormatExceptionTag(HResult → sharing/lock/access labels)Snacks/Services/TranscodingService.cs—HandleOutputPlacementpassesLogAsynccallback; per-step "Moving / Deleting" log lines with paths; richer error-log surface
PUID/PGID entrypoint
Snacks/entrypoint.sh— selective chown that skipsuploads/output;SNACKS_DISABLE_CHOWN=true|1opt-outdocker-compose.yml— PUID/PGID comment block updated; commented-outSNACKS_DISABLE_CHOWNexample
Version bumps
Snacks/Controllers/HomeController.cs— health endpoint versionSnacks/Services/ClusterDiscoveryService.cs—ClusterVersionprotocol bump to 2.12.0Snacks/Views/Shared/_Layout.cshtml— footer versionREADME.md— version badgebuild-and-export.bat— Docker tag versionelectron-app/package.json/package-lock.json— version
Full documentation: README.md