github derekshreds/Snacks v2.12.0
Snacks v2.12.0

2 hours ago

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 — when languagesToKeep is 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 helper PreferenceIndex resolves each stream's language via LanguageMatcher.ToTwoLetter with a title-inference fallback so a track whose Tags.Language is missing but whose title is "Français" still sorts by the French preference index. Unresolvable languages sort last via int.MaxValue (defensive — the upstream filter should have already dropped them).
  • TranscodingService.BuildFfmpegCommand (sidecar/OCR-mux subtitle path) — the same reorder is applied to _ffprobeService.SelectSidecarStreams results before the -map lines are emitted, so the OCR-mux path (which doesn't go through MapSub) 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 helper SidecarPreferenceIndex mirrors the MapSub resolver but takes a flat lang string because SidecarSpec doesn't carry the full Stream shape.
  • Audio reorder is implicitMapAudio already iterates _languageBuckets in 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. Default false so 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 honour default=1 (Plex, VLC, mpv, Infuse).
  • FfprobeService.MapAudio autoSetDefault parameter — appends -disposition:a:0 default and -disposition:a:N 0 for N=1..outIndex-1. Emitted after the meta block because -disposition resets all disposition flags on the target stream — we want it to win against any stale default=1 carried in via -c:a copy.
  • FfprobeService.MapSub autoSetDefault parameter — symmetric counterpart, emits -disposition:s:0 default + -disposition:s:N 0. The MuxOnly subtitle branch in TranscodingService.BuildFfmpegCommand (which builds its own maps/codecs/meta rather than calling MapSub) 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 / doSubtitleWork is 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-input carries data-reorderable="true", each chip is rendered with a fa-grip-vertical handle, made draggable="true", and given tabIndex=0. wireChipReorder handles dragstart / dragover / dragleave / drop, computing the drop slot from the cursor's X position relative to the candidate chip's midpoint. Keyboard reorder is Alt+ArrowLeft / Alt+ArrowRight on the focused chip — necessary because pure drag-and-drop fails accessibility audits. Any reorder fires a change event 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-after box-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 via data-reorderable="true". Helper text updated to mention drag-to-reorder.
  • Snacks/wwwroot/js/settings/encoder-form.js — wire AutoSetDefaultTrack and ExcludeSdhSubtitles into 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, returns true when 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 by HasAudioWork and HasSubtitleWork to 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

  • Disposition model (new in ProbeResult.cs) — captures ffprobe's per-stream disposition flags. Only HearingImpaired (and Default as a future tiebreaker) are consulted today, but every flag is captured so future features can use them without a probe-schema change.
  • FfprobeService.IsHearingImpaired — returns true when the stream's Disposition.HearingImpaired == 1 or LanguageMatcher.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 \b word 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. Default false.
  • FfprobeService.MapSub excludeSdh parameter — filters the candidate-sub list before language filtering so the SDH track never reaches keepSubs.
  • FfprobeService.SelectSidecarStreams excludeSdh parameter — same filter applied at the sidecar/OCR-mux entry point. Wired through SubtitleExtractionService.ExtractAsync and OcrBitmapsForMuxAsync.
  • MuxStreamSummary.SubtitleStreamSummary.Sdh (new field, JSON s) — persisted with the work item so HasSubtitleWork can decide mux-pass eligibility from the cached summary without re-running ffprobe. BuildSubtitleStreamSummaries (in TranscodingService) populates it via FfprobeService.IsHearingImpaired. Pre-v2.12 work-item rows deserialise the missing field as false, preserving back-compat.
  • TranscodingService.HasSubtitleWork — short-circuits to true when ExcludeSdhSubtitles is on and at least one cached summary has Sdh = 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 each renderD* node enumerated by EnumerateRenderNodes, 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 as intel with DisplayName="Intel QSV", the static LinuxIntelUsesQsv flag is set, and the VAAPI probe for that node is skipped. On Linux QSV requires the iHD driver — i965 doesn't expose QSV — so LIBVA_DRIVER_NAME=iHD is pinned before probing instead of relying on the host's default. The try/finally already in place restores the original LIBVA_DRIVER_NAME after detection.
  • TranscodingService.LinuxIntelUsesQsv (new internal static) — set to true during Linux detection when QSV probing succeeds. The static helpers GetEncoder and GetInitFlags read 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 through EncoderOptions, which is per-dispatch ephemeral state nobody else needs.

Encoder + init-flag selection

  • TranscodingService.GetEncoder overload — adds a bool linuxIntelQsv parameter; on Linux Intel jobs with the flag on, picks hevc_qsv / h264_qsv / av1_qsv instead of the VAAPI variants. AMD jobs are unaffected (the QSV flag is Intel-only). The original parameterless overload reads RuntimeInformation.IsOSPlatform(OSPlatform.Windows) and LinuxIntelUsesQsv so the call sites don't change.
  • TranscodingService.GetInitFlags 5-arg overload — adds linuxIntelQsv to 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.
  • ClusterDiscoveryService.BuildAvailableEncoders — on Linux Intel, advertises hevc_qsv / h264_qsv instead of the VAAPI encoders when LinuxIntelUsesQsv is 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 of vaapi)
    • GetInitFlags_intel_on_linux_with_qsv_and_sw_decode_derives_qsv_from_vaapi (sw-decode form, pins the vaapi=va + qsv=hw@va + filter_hw_device hw chain)
    • 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 existing RetryFileAsync (which clears the DB failure state and removes the cached _workItems entry), now also calls AutoScanService.AddSingleFileAsync so 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) return BadRequest with 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 emits WorkItemRemoved over SignalR when the failed entry is dropped from _workItems so 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.jsFailed now renders the same retry+log button group as NoSavings (it was previously folded in with Completed / 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 — adds retry(id, filePath), which optimistically removes the card after the API call returns 200 (the SignalR WorkItemRemoved arrives 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 routed data-action="retry" clicks — it now resolves the file path from the in-memory map and dispatches retry().
  • Snacks/wwwroot/js/core/signalr-client.js / main.js — registers WorkItemRemoved in the known-events list and wires it to queueManager.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 optional Func<string, Task>? onRetry callback. When supplied, retry messages are routed through that callback (typically LogAsync(workItem.Id, ...)) instead of Console.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. New EmitRetryAsync helper falls back to Console.WriteLine when 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 of IOException.HResult to 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 — passes msg => LogAsync(workItem.Id, msg) into every FileMoveAsync / FileDeleteAsync call. 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 outer catch (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 — when PUID is set, the entrypoint previously ran chown -R snacks:snacks /app/work, which on the recommended split-mount layout (/app/work/uploads bind-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 with UnauthorizedAccessException on 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 except uploads and output, which are the documented external-mount targets in docker-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/work tree is host-managed and who want zero startup chown. Accepts true or 1 (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.csExcludeSdhSubtitles, AutoSetDefaultTrack; copied through WithOverrides
  • Snacks/Models/MuxStreamSummary.csSubtitleStreamSummary.Sdh
  • Snacks/Models/ProbeResult.csStream.Disposition; new Disposition class
  • Snacks/Services/FfprobeService.csMapAudio autoSetDefault; MapSub excludeSdh/autoSetDefault + preference-order reorder; SelectSidecarStreams excludeSdh; IsHearingImpaired; PreferenceIndex
  • Snacks/Services/LanguageMatcher.csIsSdhTitle + word-boundary SDH regex
  • Snacks/Services/SubtitleExtractionService.csexcludeSdh threaded through ExtractAsync / OcrBitmapsForMuxAsync
  • Snacks/Services/TranscodingService.csWouldReorder, SidecarPreferenceIndex, OCR-mux sidecar reorder, mux-pass subtitle reorder + auto-default disposition, audio auto-default disposition, HasAudioWork / HasSubtitleWork re-mux trigger on reorder + SDH drop, Sdh populated by BuildSubtitleStreamSummaries
  • Snacks/Views/Shared/_AudioSettings.cshtml — reorderable chips, AutoSetDefaultTrack toggle
  • Snacks/Views/Shared/_SubtitleSettings.cshtml — reorderable chips, ExcludeSdhSubtitles toggle
  • Snacks/wwwroot/css/site.css — drag-to-reorder chip styles
  • Snacks/wwwroot/js/settings/chip-input.jsdata-reorderable mode + drag + keyboard reorder
  • Snacks/wwwroot/js/settings/encoder-form.jsAutoSetDefaultTrack / ExcludeSdhSubtitles read+restore
  • Snacks.Tests/Audio/AudioPlannerTests.cs — AutoSetDefault on/off
  • Snacks.Tests/Audio/LanguageMatcherTests.csIsSdhTitle theory
  • Snacks.Tests/Fixtures/ProbeBuilder.cs — Subtitle builder accepts hearingImpaired / defaultFlag / forced
  • Snacks.Tests/Subtitles/HasSubtitleWorkTests.cs — SDH-drop and reorder triggers
  • Snacks.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 in DetectHardwareAccelerationAsync; LinuxIntelUsesQsv; new GetInitFlags(..., linuxIntelQsv, ...) overload + sw-decode QSV-from-VAAPI form; GetEncoder(..., linuxIntelQsv) overload
  • Snacks/Services/ClusterDiscoveryService.cs — advertise hevc_qsv / h264_qsv when LinuxIntelUsesQsv is set
  • Snacks.Tests/Video/HardwareEncoderTests.cs — six theories pinning QSV/VAAPI selection and init flags

Retry button

  • Snacks/Controllers/QueueController.csRetry re-adds via AutoScanService.AddSingleFileAsync
  • Snacks/Services/AutoScanService.csAddSingleFileAsync
  • Snacks/Services/TranscodingService.csRetryFileAsync emits WorkItemRemoved
  • Snacks/wwwroot/js/core/signalr-client.jsWorkItemRemoved event
  • Snacks/wwwroot/js/main.js — wire WorkItemRemoved → queueManager.removeItem
  • Snacks/wwwroot/js/queue/queue-manager.jsretry, removeItem, retry-button click route
  • Snacks/wwwroot/js/queue/work-item-renderer.js — Failed renders retry+log button group

File-placement logging

  • Snacks/Services/FileService.csFileMoveAsync / FileDeleteAsync onRetry callback; EmitRetryAsync; FormatExceptionTag (HResult → sharing/lock/access labels)
  • Snacks/Services/TranscodingService.csHandleOutputPlacement passes LogAsync callback; per-step "Moving / Deleting" log lines with paths; richer error-log surface

PUID/PGID entrypoint

  • Snacks/entrypoint.sh — selective chown that skips uploads / output; SNACKS_DISABLE_CHOWN=true|1 opt-out
  • docker-compose.yml — PUID/PGID comment block updated; commented-out SNACKS_DISABLE_CHOWN example

Version bumps

  • Snacks/Controllers/HomeController.cs — health endpoint version
  • Snacks/Services/ClusterDiscoveryService.csClusterVersion protocol bump to 2.12.0
  • Snacks/Views/Shared/_Layout.cshtml — footer version
  • README.md — version badge
  • build-and-export.bat — Docker tag version
  • electron-app/package.json / package-lock.json — version

Full documentation: README.md

Don't miss a new Snacks release

NewReleases is sending notifications on new releases.