github derekshreds/Snacks v2.11.0
Snacks v2.11.0

3 hours ago

Snacks v2.11.0

Automated Media Library Transcoder

A feature release that pivots Snacks from a video-only transcoder to a media transcoder — music files (mp3 / m4a / flac / opus / ogg / wav / wma / alac / ape / aiff / dsf / dff / mka / mp2) now flow through the same scan / queue / scheduler / cluster / dashboard plumbing as video, but on a separate encoder pipeline (ConvertMusicAsync) that targets a synthetic "music" device id so audio encodes never compete for GPU video slots and a queue full of music can't starve a 4K HEVC encode (and vice versa). To keep the two kinds from drifting out of sync as more if (kind == ...) branches accreted, every kind-specific dispatch decision now lives behind a single IJobKindRouter interface — adding a new media kind is one new router class plus a DI registration, with no scattered conditionals in the dispatch / scoring / encode paths. Shared-storage mode lets master and worker skip the upload + download dance entirely when both sides mount the same NAS/SMB share — the master sends a path instead of bytes, the worker validates against an allowlist (canonicalised, symlink-resolved, with optional master↔node path rewrite), encodes to scratch, then atomic-renames into the master's pre-agreed output path. Multi-GPU support on Linux walks every /dev/dri/renderD* node so the iGPU is found even when an NVIDIA card claims renderD128 — detection records each device's actual node path and dispatch threads it through to ffmpeg's -init_hw_device vaapi=hw:/dev/dri/renderDXXX. Three operability fixes ride along: ffprobe duration parsing now pins to InvariantCulture (de-DE / fr-FR hosts no longer turn 01:39:13.45 into a 10× duration or zero kbps); the Linux container runs as PUID:PGID via gosu when those env vars are set, walking every render node to add the snacks user to whichever group actually owns it (Synology / QNAP / Unraid all use different GIDs); and queued OCR jobs emit a 5-minute keepalive log line so the per-job watchdog's 15-minute idle threshold doesn't reap a slot that's legitimately waiting for the OCR semaphore.


Music library support

MediaKind discriminator threaded end-to-end

  • Snacks/Models/MediaKind.cs (new)Video / Music enum, serialised as a JSON string (JsonStringEnumConverter) so the wire format is forward-compatible. Set at the scan boundary by FileService.GetMediaKind from the file extension and propagated unchanged through MediaFile, WorkItem, JobMetadata, JobAssignment, and EncodeHistory rows. Pre-pivot worker builds deserialise the missing field as Video (the enum's default), preserving back-compat on every protocol surface.
  • MediaFile.Kind, WorkItem.Kind, EncodeHistory.Kind, JobMetadata.Kind, JobAssignment.Kind (new fields) — every persisted and wire-level row that travels between scan, queue, dispatch, encode, and analytics carries the kind so no consumer has to guess from the extension. MediaFileRepository.UpsertAsync was the one place where kind drift could hide: bulk-insert seed paths default to Video, but AddMusicFileAsync later re-upserts the row with Music — without explicit existing.Kind = file.Kind propagation the row stayed Video forever, RestoreToQueueAsync rebuilt music WorkItems with Kind=Video after a master restart, and the cluster dispatcher routed them through the video filter and onto a video device.
  • Migration 20260505180000_AddMediaKindColumns — adds Kind INTEGER NOT NULL DEFAULT 0 to both MediaFiles and EncodeHistory. 0 corresponds to MediaKind.Video, so existing rows backfill correctly without an explicit UPDATE.

Music encoder

  • Snacks/Models/MusicEncoderOptions.cs (new) — output container/codec (m4a/mp3/opus/ogg/flac), target bitrate (kbps), optional VBR -q:a quality, sample-rate policy (Source / 44100 / 48000), channel policy (Source / Mono / Stereo), skip-ladder knobs (SkipIfAlreadyTargetCodec, BitrateMatchTolerancePct), output behaviour (DeleteOriginalFile, OutputDirectory, CopyMetadataAndArt), MasterMusicConcurrency, and DispatchToCluster. Nested under EncoderOptions.Music so a pre-pivot settings.json loads with sensible defaults and a null Music block in folder overrides simply leaves the global options untouched.
  • Snacks/Models/MusicEncoderOptionsOverride.cs (new) — nullable overlay that lets a "Lossless" folder target flac alongside an "Audiobooks" folder targeting opus 64 kbps without disturbing the global music settings. Concurrency and cluster-dispatch knobs are intentionally not overridable per-folder — they are master/global concerns.
  • Snacks/Services/MusicEncoderArgs.cs (new) — pure ffmpeg-arg builder (no process spawning, no I/O). Maps codec → ffmpeg encoder identifier, returns the right file extension for a format, classifies losslessness, exposes a stream-copy guard for embedded album art (mp3 / m4a / flac carry attached mjpeg via -c:v copy; opus / ogg encode pictures via METADATA_BLOCK_PICTURE which ffmpeg can't surface through stream-copy semantics, so cover art is dropped on those containers in v1), and composes the full -i input -map 0:a -c:a … -b:a … -ar … -ac … command line.
  • TranscodingService.AddMusicFileAsync — music-aware counterpart to AddFileAsync. Probes the file, applies the music skip ladder (lossy → lossless guard, codec + bitrate match within BitrateMatchTolerancePct), creates a MediaFile row tagged Kind=Music, queues a WorkItem tagged Kind=Music, and wakes both the local scheduler and the cluster dispatcher. The 3-strikes-and-skip retry rule from video applies; force-retry is available via the same per-folder force flag.
  • TranscodingService.ConvertMusicAsync — the actual encode. Significantly leaner than ConvertVideoAsync: no HDR detection, no hardware acceleration, no per-track audio profiles, no subtitle extraction, no retry chain. On output ≥ source the encode is classified as no-savings and the output is discarded (same semantics as the video path). ConvertMusicForRemoteAsync is the worker-side counterpart — it pins Music.OutputDirectory to the worker's scratch dir and forces DeleteOriginalFile=false so the worker never touches the master-uploaded source.
  • TranscodingService.ProcessMusicWorkItemAsync — drives status / hub / watchdog plumbing around ConvertMusicAsync and records analytics on completion. The dedicated 30 s watchdog mirrors the video path: 15 minutes without progress → cancel.
  • AnalyzeMusicFileAsync — dry-run prediction so the analyse modal sees the same Skip / Queue outcome the real run would produce.

Independent slot pool — music never starves video, and vice versa

  • Synthetic "music" device — workers advertise a virtual HardwareDevice { DeviceId = "music", IsHardware = false, DefaultConcurrency = 2 } alongside their real GPU/CPU devices in ClusterDiscoveryService.BuildDevicesWithMusic. The master's existing per-device slot ledger then accounts for music dispatches uniformly, but with its own capacity that's separate from the video pool. JobKindRouters.IsSyntheticDevice short-circuits the worker's ResolveDeviceId so music dispatches don't get rejected with "device not available on this worker" because "music" isn't part of the hardware probe.
  • Per-kind slot scoring in the cluster dispatcherClusterService.ProcessQueueAsync computes whether any free music slot or any free video slot exists once per iteration, and the dequeue filter rejects an item whose kind has no free slot anywhere. Without the early bail-out a 4K video stuck behind a music-only dispatcher would hold the whole loop hostage, and one item's failed slot pick would break and strand every other kind behind it. The skipThisTick set lets the loop continue to the next item instead of breaking when an item can't be placed.
  • Master-local music slot via shared SlotLedgerTranscodingService.ProcessQueueAsync reserves master-local music encodes against (_localNodeId, "music") through the same SlotLedger that workers use. Capacity is read live from EncoderOptions.Music.MasterMusicConcurrency on every reservation so a settings change takes effect on the very next iteration without resizing a semaphore. MasterMusicConcurrency=0 makes TryReserve refuse every music slot so the cluster dispatcher then routes the item to a worker.
  • WorkerCapabilities.SupportsMusic (new field) — defaults to true for any node running 2.11+. Pre-pivot worker builds deserialise the missing JSON field as false, so the master automatically routes music jobs only to nodes that can run them.

Per-kind dispatch routers

  • Snacks/Services/Routing/IJobKindRouter.cs (new) — one router per MediaKind. Each router owns: the SyntheticDeviceId it claims (or null for real-hardware kinds), how to pin the worker's EncoderOptions.HardwareAcceleration / HardwareDevicePath for a remote dispatch, how to score a (node, device) slot for an item of this kind, which TranscodingService entry point to call to actually run the encode, and what file extension the worker will produce. Adding a new kind = one new router class plus a DI registration in Program.cs — no scattered if (kind == …) branches in dispatch / encode / scoring.
  • Snacks/Services/Routing/JobKindRouters.cs (new) — registry of IJobKindRouter implementations looked up by MediaKind. Constructed once at DI time and shared by ClusterService (slot scoring, dispatch, expected output extension) and ClusterNodeJobService (synthetic device resolution, encode dispatch).
  • Snacks/Services/Routing/VideoJobRouter.cs (new) — owns video scoring (the pre-extraction logic that lived in ClusterService.ScoreSlot: codec match, 4K node preferences, hwaccel-vendor pinning, CPU exclusion under "auto + usable HW"). Pins HardwareAcceleration and HardwareDevicePath on dispatch so VAAPI jobs land on the master's chosen render node — see Multi-GPU below.
  • Snacks/Services/Routing/MusicJobRouter.cs (new) — owns music scoring (device.DeviceId == "music" + node.Capabilities.SupportsMusic == true + Music.DispatchToCluster != false, with a "least-loaded music node wins" tiebreaker). Pins nothing — passing -hwaccel music to ffmpeg crashes it; music is pure CPU.
  • Snacks/Services/Routing/ScoreContext.cs — read-only view of the data routers need to score a slot, passed in by the dispatcher rather than letting routers reach into ClusterService. Keeps the router surface narrow and unit-testable.

Cluster dispatcher wakes on enqueue (so workers actually compete for music)

  • TranscodingService.SetWorkItemQueuedCallbackClusterService wires this to its dispatch entry point so the cluster dispatcher races the master-local scheduler per-item via the queue lock. The cluster's 2-second timer alone wasn't fast enough for music: encodes finish in seconds, the master's event-driven local loop wakes immediately on enqueue, and workers ended up sitting idle while the master burned through every queued music item between ticks.

Dashboard media-type filter

  • Dashboard chip strip — All / Video / Music — every EncodeHistoryRepository aggregation (GetSummaryAsync, GetSavingsOverTimeAsync, GetDeviceUtilizationAsync, GetCodecMixAsync, GetNodeThroughputAsync, GetRecentAsync, GetTopSavingsAsync) now takes an optional MediaKind? filter. Without it, a 90%-reduction flac → AAC encode would dominate the "top compression wins" leaderboard against any video encode, and the device utilisation stripe would blend GPU-heavy 4K encodes with single-thread music CPU work into one unreadable bar.
  • HistorySummary.VideoEncodes / MusicEncodes (new) — counts attributed by kind, surfaced in the hero subtitle as a "video / music" split.
  • DashboardController and ClusterController dashboard/* endpoints — accept ?kind=video|music and forward it through the proxy chain so a node-mode master sees the same filter applied to the upstream master's data.

Library browser is media-aware

  • LibraryController.GetMediaFiles (renamed from GetVideoFiles-style logic) — returns video + music in one response, each entry tagged with kind: "Video" | "Music" so the browser can show distinct icons and counts.
  • FileService.GetAllMediaFiles(directories) and IsMusicFile — non-recursive listing plus extension classification mirroring the existing GetAllVideoFiles / IsVideoFile shape. The [snacks]-suffix filter on IsMusicFile rejects music outputs the same way it does video outputs. AutoScanService walks both kinds in one pass; the snacks-output companion check accepts a [snacks].* companion of either kind via an inline extension list (IsVideoFile / IsMusicFile both reject the [snacks] suffix outright, so they can't be reused there).
  • Snacks/Views/Shared/_AppModals.cshtml — modal renamed to "Media Library Browser"; file list icon switches from fa-file-video to fa-photo-film.

Music settings UI

  • Snacks/Views/Shared/_MusicSettings.cshtml (new) — Music settings tab partial under the main Settings modal: container/codec dropdown, target bitrate (with a "FLAC ignores this" explainer), sample rate, channel layout, skip-ladder toggle, bitrate match tolerance, copy-metadata-and-art toggle, delete-original toggle, master concurrency, and a "Dispatch music jobs to cluster workers" toggle (visible only on master/standalone roles).

Shared-storage mode (skip upload + download when master and node mount the same share)

Symmetric opt-in flag — fail-closed by default

  • ClusterConfig.SharedStorageEnabled (new field) — both master and node must enable it. Master only offers shared mode when its own flag is on; the node only honours a shared dispatch when its own flag is on. If either side is off (or the node rejects the path) the regular upload/download flow runs, so this is safe to flip on without coordination — defaults to false everywhere.
  • ClusterConfig.SharedStorageInputPaths and SharedStorageOutputPaths (new) — node-side allowlists. Empty list rejects all shared-mode dispatches (fail closed). Separated so a read-only NAS export can serve as the source while output goes to a different writable location.
  • ClusterConfig.SharedStoragePathRewriteFrom / SharedStoragePathRewriteTo (new) — optional path translation applied on the node before the allowlist check. Lets the master see the share at /shared/movies while the node mounts it at /mnt/nas/movies. Single from/to pair — set both or neither.
  • NodeSettings.DisableSharedStorage (new) — per-node opt-out so a master can keep the cluster-wide flag on but force the upload path for a specific off-LAN node that doesn't have the shared mount. null = inherit cluster default.

SharedStoragePathValidator — canonicalise → resolve symlinks → allowlist

  • Snacks/Services/Cluster/SharedStoragePathValidator.cs (new) — validates a path the master sent against the node's allowlists. Pipeline: optional rewrite → Path.GetFullPathFileInfo.ResolveLinkTarget(returnFinalTarget: true) → canonicalise again → trailing-separator prefix check. The two-step canonicalise defends against an allowlisted directory containing a symlink that points outside the allowlist; the trailing-separator check stops /foo from matching /foobar. Honours OrdinalIgnoreCase on Windows and Ordinal elsewhere — same image ships to both. Probes a 4-byte read on the input and a write-then-delete on the output dir up front so a stale mount or permission error fails dispatch immediately rather than after the encode has started.
  • Snacks/Models/MetadataAck.cs (new) — node's reply after metadata registration. Mode = "shared" when shared was accepted, "upload" (the default and back-compat answer) otherwise. Reason carries a human-readable explanation for fallback so the master can surface it in operator logs.
  • Snacks.Tests/Cluster/SharedStoragePathValidatorTests.cs (new) — pins canonicalisation, the trailing-separator boundary, the .. traversal rejection, the read-probe failure path, the rewrite (only the prefix gets rewritten), the case-sensitivity gate, the missing-input fallback, and the empty-allowlist fail-closed behaviour.

JobMetadata carries the master's path; RegisterMetadata returns shared-or-upload

  • JobMetadata.SharedStorageInputPath / SharedStorageOutputPath / SharedStorageOutputDirectory (new) — populated by the master only when offerShared is satisfied (SharedStorageEnabled on cluster config and not DisableSharedStorage on the target node). The node validates these against its own allowlists; if validation succeeds, it writes a _shared.json sentinel into the job's temp dir and fires StartAutonomousEncodingAsync immediately because no upload chunk will arrive to fire it.
  • ClusterController.RegisterMetadata — on shared-mode acceptance, returns MetadataAck { Mode = "shared", ResolvedInputPath, ResolvedOutputPath } so the master logs the resolved paths for diagnostics and skips UploadFileToNodeAsync. Older nodes reply with the prior { registered: true } body which ClusterFileTransferService.TryParseAck treats as "upload" for back-compat.
  • ClusterFileTransferService.RegisterMetadataAsync — return type changes from Task to Task<MetadataAck> to surface the node's mode decision back to ClusterService.DispatchToNodeAsync. Parsing is defensive: malformed body, missing mode field, or any deserialisation throw downgrades to "upload" mode.

Master-side dispatch and completion

  • ClusterService._sharedJobOutputPaths — concurrent dictionary keyed by jobId, holding the master's expected output path. Set after the node accepts shared mode, consulted by HandleRemoteCompletionAsync to skip the download phase, and cleared as part of normal job teardown (completion / cancel / dispatch failure). Never persisted — a master crash invalidates the in-flight job and recovery re-dispatches under whichever mode is currently negotiated.
  • DispatchToNodeAsync — when offerShared is true and the node accepts, the master skips AcquireUploadAsync + UploadFileToNodeAsync + the upload-verification round-trip entirely, sets workItem.TransferProgress = 100 so the dashboard reflects the no-upload reality, and logs the master/node-resolved paths.
  • HandleRemoteCompletionAsync — in shared mode, polls for the file at the pre-agreed shared path (with retry on missing file because the worker's atomic-rename may still be settling) instead of calling DownloadOutputAsync. Validation + history recording run unchanged.
  • Per-kind output extension on the master — the master's expected output filename is now {baseName} [snacks]{router.ExpectedOutputExtension(options)}. Without this, every download / shared-mode poll forced .mkv/.mp4 and a music encode landed at the master as track [snacks].mkv next to the source — playable as audio but cosmetically wrong and confusing for downstream consumers that key on extension.

Worker-side encode and atomic placement

  • ClusterNodeJobService.RunEncodeAsync — reads _shared.json (TryReadSharedSentinel) at the start, encodes to scratch as before, and on success moves the encoded output from the scratch directory to the master's pre-agreed shared location via tmp-suffix + atomic rename so the master never sees a partial file. Failure to place the shared output downgrades the job to a failure (the master is polling an empty path) and reports it back through the existing /api/cluster/jobs/{jobId}/failed channel.
  • Recovery short-circuit guard — the existing "output already on disk → skip encode" recovery path is gated to !File.Exists(_shared.json) because in shared mode the master expects the file at the pre-agreed shared path, not in scratch. A recovered scratch-only file would never be visible to the master; re-encoding is wasteful but correct in that race.
  • Per-kind encoder dispatchRunEncodeAsync now calls _routers.For(workItem.Kind).EncodeRemoteAsync(...) instead of hard-wiring ConvertVideoForRemoteAsync. Same router chain that pins HardwareAcceleration and HardwareDevicePath for video and no-ops for music.

ClusterAdminController and _ClusterSettings.cshtml

  • GET /api/cluster-admin/config — config payload now includes SharedStorageEnabled, SharedStorageInputPaths, SharedStorageOutputPaths, SharedStoragePathRewriteFrom, SharedStoragePathRewriteTo so the settings UI can render and persist them.
  • Snacks/Views/Shared/_ClusterSettings.cshtml — new "Shared Storage" panel under cluster settings (visible for both master and node since the flag is symmetric): on/off toggle, allowed input directories textarea, allowed output directories textarea, an advanced <details> for path rewrite, and a warning callout when shared is enabled but no allowlist entries exist.

Multi-GPU support on Linux (hybrid laptops, multi-card servers)

Detection walks every /dev/dri/renderD* node, not just renderD128

  • TranscodingService.EnumerateRenderNodes — returns every /dev/dri/renderD* node sorted by trailing index. On hybrid laptops (e.g. Pop!_OS in hybrid/nvidia/compute mode) the NVIDIA card can claim renderD128 while the iGPU lands on renderD129. The legacy single-node probe failed against the NVIDIA node (no iHD/i965 VAAPI support there) and silently skipped VAAPI entirely — even though a perfectly good iGPU was sitting on the next node.
  • VAAPI auto-detect loop — for each render node, try every driver (iHD, i965) and run a fast HEVC/H264/AV1 encode probe. The first node that passes is recorded with its actual DevicePath.
  • vainfo diagnostic logs the chosen node — output prefixed with Auto-detect vainfo (/dev/dri/renderD129) so an operator can correlate logs with the right adapter on a hybrid system.

HardwareDevice.DevicePath carries the node path through dispatch

  • HardwareDevice.DevicePath (new) — filesystem path to the underlying device node when one applies (Linux VAAPI). Null for vendor families that don't use a node selector (NVIDIA cuda, Windows QSV/AMF/NVENC, macOS VideoToolbox, the synthetic CPU device).
  • EncoderOptions.HardwareDevicePath (new) — per-dispatch ephemeral state, not persisted to settings.json. Resolved at dispatch from the chosen HardwareDevice.DevicePath so each ffmpeg invocation targets the correct GPU.
  • TranscodingService.GetDevicePathForDeviceId — exposed accessor used by the local scheduler and the VideoJobRouter to fill in HardwareDevicePath from the locally detected device list.
  • ResolveHardwareAccelerationAsync auto-fills the path — once "auto" resolves to a concrete vendor (intel / amd / nvidia / apple), HardwareDevicePath is filled from the matching detected device unless the caller already pinned it (test fixture exercising a specific node).

GetInitFlags overload threads the path into ffmpeg's hwaccel init

  • GetInitFlags(string, string?, bool, bool) (new overload) — VAAPI branches now emit -init_hw_device vaapi=hw:{devicePath} when set, falling back to /dev/dri/renderD128 when null so single-GPU machines stay byte-identical to the pre-fix output. Called from BuildFfmpegCommand, the bitrate-calibration sweep (MeasureBitrateAtQp), the cropdetect probe, and every other dispatch site.
  • NVIDIA / QSV / AMF / VideoToolbox branches unchanged — they don't use a node selector.

Container drops-in are aware of the second adapter

  • Snacks/entrypoint.sh — when PUID is set, walks every /dev/dri/renderD* and adds the snacks user to whichever group actually owns each node so hardware acceleration keeps working after dropping privileges. The previous renderD128-only check silently locked us out of the iGPU when it landed on renderD129.

Tests

  • Snacks.Tests/Video/HardwareEncoderTests.cs — the new GetInitFlags_uses_supplied_device_path theory pins the path-aware overload across all four VAAPI branches (intel/amd × hw-decode on/off); GetInitFlags_null_path_falls_back_to_renderD128 is the regression guard so a null path produces the byte-identical legacy flag string.
  • Snacks.Tests/Pipeline/FullCommandScenarioTests.cs — new "Scenario 7b" wires the full Intel VAAPI hybrid-laptop scenario end-to-end and asserts the rendered command uses /dev/dri/renderD129 and explicitly does not contain renderD128.

Locale-safe ffprobe duration parsing

InvariantCulture parsing of ffmpeg/ffprobe duration strings

  • FfprobeService.DurationStringToSeconds — now parses with NumberStyles.Float, CultureInfo.InvariantCulture. ffprobe and ffmpeg always emit . as the decimal separator regardless of host locale; current-culture parsing on hosts like de-DE / fr-FR (where , is the decimal separator) treated the . as a thousands separator, producing either a parse exception or a value 10^N too large. User-visible symptom: 01:39:13.45 either threw mid-encode (zero kbps reported, progress stuck near 0%) or became 5 953 450 seconds, depending on which length value was being parsed.
  • TranscodingService measured-bitrate parser and AddMusicFileAsync duration parse — same fix in the two other places where ffmpeg's progress / format duration is read.

Tests

  • Snacks.Tests/Video/DurationParsingCultureTests.cs (new)RunInGermanCulture swaps CultureInfo.CurrentCulture to de-DE, asserts DurationStringToSeconds("5953.234567") ≈ 5953.234567 and "01:39:13.45" ≈ 5953.45. Restores prior culture in finally so a parallel test run can't leak.

Linux containers: drop privileges to PUID/PGID, find every render group

gosu-based runtime user remap

  • Snacks/entrypoint.sh (new) — when PUID (and optionally PGID) are set, usermod -o -u $PUID snacks retargets the pre-created snacks user, chown -R snacks:snacks /app/work aligns the work dir with the NAS owner so files written to a shared mount have the correct uid/gid (vital for SMB/NFS shares where Snacks isn't the only writer), and gosu snacks dotnet Snacks.dll drops privileges before the runtime starts. When PUID is unset, dotnet Snacks.dll runs as root — the original behaviour, preserved so existing deployments don't change semantics on upgrade.
  • Render-group walk — for every /dev/dri/renderD* node, read its group via stat -c '%g' and usermod -aG the snacks user into whichever group owns it. Necessary because the render group's GID varies across NAS distributions (Synology, QNAP, Unraid all assign different IDs) and on hybrid systems different render nodes may belong to different groups.
  • Snacks/Dockerfile — pre-creates snacks UID 1000 (after deleting Ubuntu Noble's pre-shipped ubuntu user at the same UID to avoid the collision); installs gosu from apt; copies entrypoint.sh and chmod +xs it; replaces the bare ENTRYPOINT ["dotnet", "Snacks.dll"] with the entrypoint script.
  • docker-compose.yml — adds commented-out PUID=1000 / PGID=1000 env entries with a one-paragraph explainer so users can flip them on without reading the Dockerfile.

OCR queued-slot keepalive

5-minute log line keeps the per-job watchdog from reaping queued OCR work

  • NativeOcrService.AcquireSlotAsync — replaces await _ocrSlot.WaitAsync(ct) with a poll loop on a 5-minute timeout that emits OCR: still waiting for node OCR slot — held by '{holder}'. The per-job watchdog kills items whose LastUpdatedAt is stale for 15 minutes; a bare WaitAsync(ct) let queued OCR jobs (high concurrency × many OCR streams per video) sit silent past that threshold and get reaped while the slot-holder was still legitimately working.

Files Changed

MediaKind / Music pipeline

  • Snacks/Models/MediaKind.cs (new) — Video/Music discriminator
  • Snacks/Models/MusicEncoderOptions.cs (new)
  • Snacks/Models/MusicEncoderOptionsOverride.cs (new)
  • Snacks/Models/EncoderOptions.csMusic nested options + HardwareDevicePath
  • Snacks/Models/EncoderOptionsOverride.csMusicOverride nullable overlay
  • Snacks/Models/MediaFile.cs / WorkItem.cs / EncodeHistory.cs / JobMetadata.cs / JobAssignment.csKind field
  • Snacks/Models/ClusterNode.csWorkerCapabilities.SupportsMusic
  • Snacks/Data/Migrations/20260505180000_AddMediaKindColumns.cs (new)
  • Snacks/Data/MediaFileRepository.cs — propagate Kind on upsert
  • Snacks/Data/EncodeHistoryRepository.csMediaKind? filter on every aggregation; VideoEncodes/MusicEncodes summary fields
  • Snacks/Services/MusicEncoderArgs.cs (new) — pure ffmpeg-arg builder for music
  • Snacks/Services/TranscodingService.csAddMusicFileAsync, ConvertMusicAsync, ConvertMusicForRemoteAsync, ProcessMusicWorkItemAsync, AnalyzeMusicFileAsync, music slot via shared SlotLedger, per-kind queue scan, SetWorkItemQueuedCallback, GetMusicOutputPath
  • Snacks/Services/FileService.cs_musicExtensions, IsMusicFile, GetMediaKind, GetAllMusicFiles, GetAllMediaFiles
  • Snacks/Services/AutoScanService.cs — scans video + music in one pass; [snacks] companion accepts both kinds
  • Snacks/Services/ClusterDiscoveryService.cs — synthetic "music" device on every worker; SupportsMusic = true
  • Snacks/Controllers/LibraryController.cs — returns video + music with kind field
  • Snacks/Controllers/DashboardController.cs / ClusterController.cs?kind= filter on every dashboard endpoint
  • Snacks/Views/Shared/_MusicSettings.cshtml (new) — Music settings tab partial
  • Snacks/Views/Shared/_AppModals.cshtml — Music tab; "Media Library Browser" rename
  • Snacks/Views/Dashboard/Index.cshtml — All / Video / Music chip strip
  • Snacks/wwwroot/js/dashboard/dashboard.js / library/library-browser.js / settings/encoder-form.js — media-type filter wiring; music form bindings; library kind icons

Per-kind dispatch routers

  • Snacks/Services/Routing/IJobKindRouter.cs (new)
  • Snacks/Services/Routing/JobKindRouters.cs (new) — registry + IsSyntheticDevice
  • Snacks/Services/Routing/VideoJobRouter.cs (new) — extracted scoring
  • Snacks/Services/Routing/MusicJobRouter.cs (new)
  • Snacks/Program.cs — DI registrations for both routers and the registry
  • Snacks/Services/ClusterService.cs_routers injection, _scoreContext, per-kind scoring, per-kind output extension at completion
  • Snacks/Services/ClusterNodeJobService.cs_routers.For(kind).EncodeRemoteAsync(...); synthetic "music" device short-circuit in ResolveDeviceId

Shared-storage mode

  • Snacks/Services/Cluster/SharedStoragePathValidator.cs (new) — canonicalise / resolve / allowlist
  • Snacks/Models/MetadataAck.cs (new)
  • Snacks/Models/ClusterConfig.csSharedStorageEnabled, allowlists, rewrite from/to
  • Snacks/Models/NodeSettings.csDisableSharedStorage per-node opt-out
  • Snacks/Models/JobMetadata.csSharedStorageInputPath / OutputPath / OutputDirectory
  • Snacks/Controllers/ClusterController.cs — async RegisterMetadata with MetadataAck reply; _shared.json sentinel; immediate StartAutonomousEncodingAsync
  • Snacks/Controllers/ClusterAdminController.cs — config payload includes shared-storage fields
  • Snacks/Services/ClusterFileTransferService.csRegisterMetadataAsync returns Task<MetadataAck>; TryParseAck defensive parsing
  • Snacks/Services/ClusterService.cs_sharedJobOutputPaths map; dispatch skips upload in shared mode; completion polls for shared output instead of downloading
  • Snacks/Services/ClusterNodeJobService.csTryReadSharedSentinel; atomic-rename to shared output; recovery short-circuit gated by sentinel
  • Snacks/Views/Shared/_ClusterSettings.cshtml — Shared Storage panel
  • Snacks/wwwroot/js/cluster/cluster-settings-form.js — load/save the new fields
  • Snacks.Tests/Cluster/SharedStoragePathValidatorTests.cs (new)

Linux multi-GPU

  • Snacks/Services/TranscodingService.csEnumerateRenderNodes, GetDevicePathForDeviceId, path-aware GetInitFlags, ResolveHardwareAccelerationAsync auto-fills path; vainfo logs the chosen node
  • Snacks/Models/HardwareDevice.csDevicePath
  • Snacks/Models/EncoderOptions.csHardwareDevicePath per-dispatch
  • Snacks/entrypoint.sh — render-group walk across every renderD*
  • Snacks.Tests/Video/HardwareEncoderTests.cs — path-aware overload pins
  • Snacks.Tests/Pipeline/FullCommandScenarioTests.cs — Scenario 7b end-to-end

Foreign-culture duration parsing

  • Snacks/Services/FfprobeService.csInvariantCulture for DurationStringToSeconds
  • Snacks/Services/TranscodingService.cs — same fix in measured-bitrate parser and music duration probe
  • Snacks.Tests/Video/DurationParsingCultureTests.cs (new)

Linux container PUID/PGID

  • Snacks/Dockerfile — pre-create snacks UID 1000, install gosu, drop the Noble ubuntu user, copy entrypoint.sh
  • Snacks/entrypoint.sh (new) — usermod + chown + gosu privilege drop
  • docker-compose.yml — commented PUID/PGID env block

OCR keepalive

  • Snacks/Services/Ocr/NativeOcrService.cs — 5-minute poll-loop log line in AcquireSlotAsync

Master-local SlotLedger integration

  • Snacks/Services/TranscodingService.csSetSlotLedger, _localNodeId stamping, TryReserveLocalDeviceSlot via ledger, Release in continuation; UpdateOptions wakes scheduler
  • Snacks/Services/ClusterService.csUpdateLocalSelfNode registers master in _nodes for ledger capacity; consolidated ReleaseActiveSlot in HandleNodeFailureAsync; ledger release on "source removed during encoding" path

Version bumps

  • Snacks/Controllers/HomeController.cs — health endpoint version
  • Snacks/Services/ClusterDiscoveryService.csClusterVersion protocol bump to 2.11.0
  • Snacks/Views/Shared/_Layout.cshtml — footer version + window title rename
  • README.md — badge, footer, "Media Library Transcoder" title, NVIDIA-in-Docker section, hybrid-GPU note
  • build-and-export.bat — Docker tag version
  • electron-app/package.json / package-lock.json — version + description rename to "Automated media transcoding"

Full documentation: README.md

Don't miss a new Snacks release

NewReleases is sending notifications on new releases.