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/Musicenum, serialised as a JSON string (JsonStringEnumConverter) so the wire format is forward-compatible. Set at the scan boundary byFileService.GetMediaKindfrom the file extension and propagated unchanged throughMediaFile,WorkItem,JobMetadata,JobAssignment, andEncodeHistoryrows. Pre-pivot worker builds deserialise the missing field asVideo(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.UpsertAsyncwas the one place where kind drift could hide: bulk-insert seed paths default toVideo, butAddMusicFileAsynclater re-upserts the row withMusic— without explicitexisting.Kind = file.Kindpropagation the row stayedVideoforever,RestoreToQueueAsyncrebuilt music WorkItems withKind=Videoafter a master restart, and the cluster dispatcher routed them through the video filter and onto a video device.- Migration
20260505180000_AddMediaKindColumns— addsKind INTEGER NOT NULL DEFAULT 0to bothMediaFilesandEncodeHistory.0corresponds toMediaKind.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:aquality, sample-rate policy (Source/44100/48000), channel policy (Source/Mono/Stereo), skip-ladder knobs (SkipIfAlreadyTargetCodec,BitrateMatchTolerancePct), output behaviour (DeleteOriginalFile,OutputDirectory,CopyMetadataAndArt),MasterMusicConcurrency, andDispatchToCluster. Nested underEncoderOptions.Musicso a pre-pivotsettings.jsonloads with sensible defaults and anullMusicblock 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 attachedmjpegvia-c:v copy; opus / ogg encode pictures viaMETADATA_BLOCK_PICTUREwhich 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 toAddFileAsync. Probes the file, applies the music skip ladder (lossy → lossless guard, codec + bitrate match withinBitrateMatchTolerancePct), creates aMediaFilerow taggedKind=Music, queues aWorkItemtaggedKind=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-folderforceflag.TranscodingService.ConvertMusicAsync— the actual encode. Significantly leaner thanConvertVideoAsync: no HDR detection, no hardware acceleration, no per-track audio profiles, no subtitle extraction, no retry chain. Onoutput ≥ sourcethe encode is classified as no-savings and the output is discarded (same semantics as the video path).ConvertMusicForRemoteAsyncis the worker-side counterpart — it pinsMusic.OutputDirectoryto the worker's scratch dir and forcesDeleteOriginalFile=falseso the worker never touches the master-uploaded source.TranscodingService.ProcessMusicWorkItemAsync— drives status / hub / watchdog plumbing aroundConvertMusicAsyncand 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 sameSkip/Queueoutcome the real run would produce.
Independent slot pool — music never starves video, and vice versa
- Synthetic
"music"device — workers advertise a virtualHardwareDevice { DeviceId = "music", IsHardware = false, DefaultConcurrency = 2 }alongside their real GPU/CPU devices inClusterDiscoveryService.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.IsSyntheticDeviceshort-circuits the worker'sResolveDeviceIdso 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 dispatcher —
ClusterService.ProcessQueueAsynccomputes 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 wouldbreakand strand every other kind behind it. TheskipThisTickset lets the loop continue to the next item instead of breaking when an item can't be placed. - Master-local music slot via shared
SlotLedger—TranscodingService.ProcessQueueAsyncreserves master-local music encodes against(_localNodeId, "music")through the sameSlotLedgerthat workers use. Capacity is read live fromEncoderOptions.Music.MasterMusicConcurrencyon every reservation so a settings change takes effect on the very next iteration without resizing a semaphore.MasterMusicConcurrency=0makesTryReserverefuse every music slot so the cluster dispatcher then routes the item to a worker. WorkerCapabilities.SupportsMusic(new field) — defaults totruefor any node running 2.11+. Pre-pivot worker builds deserialise the missing JSON field asfalse, 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 perMediaKind. Each router owns: theSyntheticDeviceIdit claims (ornullfor real-hardware kinds), how to pin the worker'sEncoderOptions.HardwareAcceleration/HardwareDevicePathfor a remote dispatch, how to score a(node, device)slot for an item of this kind, whichTranscodingServiceentry 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 inProgram.cs— no scatteredif (kind == …)branches in dispatch / encode / scoring.Snacks/Services/Routing/JobKindRouters.cs(new) — registry ofIJobKindRouterimplementations looked up byMediaKind. Constructed once at DI time and shared byClusterService(slot scoring, dispatch, expected output extension) andClusterNodeJobService(synthetic device resolution, encode dispatch).Snacks/Services/Routing/VideoJobRouter.cs(new) — owns video scoring (the pre-extraction logic that lived inClusterService.ScoreSlot: codec match, 4K node preferences, hwaccel-vendor pinning, CPU exclusion under "auto + usable HW"). PinsHardwareAccelerationandHardwareDevicePathon 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 musicto 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 intoClusterService. Keeps the router surface narrow and unit-testable.
Cluster dispatcher wakes on enqueue (so workers actually compete for music)
TranscodingService.SetWorkItemQueuedCallback—ClusterServicewires 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
EncodeHistoryRepositoryaggregation (GetSummaryAsync,GetSavingsOverTimeAsync,GetDeviceUtilizationAsync,GetCodecMixAsync,GetNodeThroughputAsync,GetRecentAsync,GetTopSavingsAsync) now takes an optionalMediaKind?filter. Without it, a 90%-reductionflac → AACencode 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.DashboardControllerandClusterControllerdashboard/*endpoints — accept?kind=video|musicand 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 fromGetVideoFiles-style logic) — returns video + music in one response, each entry tagged withkind: "Video" | "Music"so the browser can show distinct icons and counts.FileService.GetAllMediaFiles(directories)andIsMusicFile— non-recursive listing plus extension classification mirroring the existingGetAllVideoFiles/IsVideoFileshape. The[snacks]-suffix filter onIsMusicFilerejects music outputs the same way it does video outputs.AutoScanServicewalks both kinds in one pass; the snacks-output companion check accepts a[snacks].*companion of either kind via an inline extension list (IsVideoFile/IsMusicFileboth 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 fromfa-file-videotofa-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 tofalseeverywhere.ClusterConfig.SharedStorageInputPathsandSharedStorageOutputPaths(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/movieswhile 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.GetFullPath→FileInfo.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/foofrom matching/foobar. HonoursOrdinalIgnoreCaseon Windows andOrdinalelsewhere — 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.Reasoncarries 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 whenofferSharedis satisfied (SharedStorageEnabledon cluster config and notDisableSharedStorageon the target node). The node validates these against its own allowlists; if validation succeeds, it writes a_shared.jsonsentinel into the job's temp dir and firesStartAutonomousEncodingAsyncimmediately because no upload chunk will arrive to fire it.ClusterController.RegisterMetadata— on shared-mode acceptance, returnsMetadataAck { Mode = "shared", ResolvedInputPath, ResolvedOutputPath }so the master logs the resolved paths for diagnostics and skipsUploadFileToNodeAsync. Older nodes reply with the prior{ registered: true }body whichClusterFileTransferService.TryParseAcktreats as"upload"for back-compat.ClusterFileTransferService.RegisterMetadataAsync— return type changes fromTasktoTask<MetadataAck>to surface the node's mode decision back toClusterService.DispatchToNodeAsync. Parsing is defensive: malformed body, missingmodefield, 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 byHandleRemoteCompletionAsyncto 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— whenofferSharedis true and the node accepts, the master skipsAcquireUploadAsync+UploadFileToNodeAsync+ the upload-verification round-trip entirely, setsworkItem.TransferProgress = 100so 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 callingDownloadOutputAsync. 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/.mp4and a music encode landed at the master astrack [snacks].mkvnext 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}/failedchannel.- 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 dispatch —
RunEncodeAsyncnow calls_routers.For(workItem.Kind).EncodeRemoteAsync(...)instead of hard-wiringConvertVideoForRemoteAsync. Same router chain that pinsHardwareAccelerationandHardwareDevicePathfor video and no-ops for music.
ClusterAdminController and _ClusterSettings.cshtml
GET /api/cluster-admin/config— config payload now includesSharedStorageEnabled,SharedStorageInputPaths,SharedStorageOutputPaths,SharedStoragePathRewriteFrom,SharedStoragePathRewriteToso 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 claimrenderD128while the iGPU lands onrenderD129. 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 actualDevicePath. vainfodiagnostic logs the chosen node — output prefixed withAuto-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 tosettings.json. Resolved at dispatch from the chosenHardwareDevice.DevicePathso each ffmpeg invocation targets the correct GPU.TranscodingService.GetDevicePathForDeviceId— exposed accessor used by the local scheduler and theVideoJobRouterto fill inHardwareDevicePathfrom the locally detected device list.ResolveHardwareAccelerationAsyncauto-fills the path — once "auto" resolves to a concrete vendor (intel / amd / nvidia / apple),HardwareDevicePathis 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/renderD128when null so single-GPU machines stay byte-identical to the pre-fix output. Called fromBuildFfmpegCommand, 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— whenPUIDis 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 previousrenderD128-only check silently locked us out of the iGPU when it landed onrenderD129.
Tests
Snacks.Tests/Video/HardwareEncoderTests.cs— the newGetInitFlags_uses_supplied_device_paththeory pins the path-aware overload across all four VAAPI branches (intel/amd × hw-decode on/off);GetInitFlags_null_path_falls_back_to_renderD128is the regression guard so anullpath 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/renderD129and explicitly does not containrenderD128.
Locale-safe ffprobe duration parsing
InvariantCulture parsing of ffmpeg/ffprobe duration strings
FfprobeService.DurationStringToSeconds— now parses withNumberStyles.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.45either threw mid-encode (zero kbps reported, progress stuck near 0%) or became 5 953 450 seconds, depending on which length value was being parsed.TranscodingServicemeasured-bitrate parser andAddMusicFileAsyncduration parse — same fix in the two other places where ffmpeg's progress / format duration is read.
Tests
Snacks.Tests/Video/DurationParsingCultureTests.cs(new) —RunInGermanCultureswapsCultureInfo.CurrentCulturetode-DE, assertsDurationStringToSeconds("5953.234567")≈ 5953.234567 and"01:39:13.45"≈ 5953.45. Restores prior culture infinallyso 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) — whenPUID(and optionallyPGID) are set,usermod -o -u $PUID snacksretargets the pre-created snacks user,chown -R snacks:snacks /app/workaligns 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), andgosu snacks dotnet Snacks.dlldrops privileges before the runtime starts. WhenPUIDis unset,dotnet Snacks.dllruns 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 viastat -c '%g'andusermod -aGthe 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-createssnacksUID 1000 (after deleting Ubuntu Noble's pre-shippedubuntuuser at the same UID to avoid the collision); installsgosufrom apt; copiesentrypoint.shandchmod +xs it; replaces the bareENTRYPOINT ["dotnet", "Snacks.dll"]with the entrypoint script.docker-compose.yml— adds commented-outPUID=1000/PGID=1000env 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— replacesawait _ocrSlot.WaitAsync(ct)with a poll loop on a 5-minute timeout that emitsOCR: still waiting for node OCR slot — held by '{holder}'. The per-job watchdog kills items whoseLastUpdatedAtis stale for 15 minutes; a bareWaitAsync(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 discriminatorSnacks/Models/MusicEncoderOptions.cs(new)Snacks/Models/MusicEncoderOptionsOverride.cs(new)Snacks/Models/EncoderOptions.cs—Musicnested options +HardwareDevicePathSnacks/Models/EncoderOptionsOverride.cs—MusicOverridenullable overlaySnacks/Models/MediaFile.cs/WorkItem.cs/EncodeHistory.cs/JobMetadata.cs/JobAssignment.cs—KindfieldSnacks/Models/ClusterNode.cs—WorkerCapabilities.SupportsMusicSnacks/Data/Migrations/20260505180000_AddMediaKindColumns.cs(new)Snacks/Data/MediaFileRepository.cs— propagateKindon upsertSnacks/Data/EncodeHistoryRepository.cs—MediaKind?filter on every aggregation;VideoEncodes/MusicEncodessummary fieldsSnacks/Services/MusicEncoderArgs.cs(new) — pure ffmpeg-arg builder for musicSnacks/Services/TranscodingService.cs—AddMusicFileAsync,ConvertMusicAsync,ConvertMusicForRemoteAsync,ProcessMusicWorkItemAsync,AnalyzeMusicFileAsync, music slot via sharedSlotLedger, per-kind queue scan,SetWorkItemQueuedCallback,GetMusicOutputPathSnacks/Services/FileService.cs—_musicExtensions,IsMusicFile,GetMediaKind,GetAllMusicFiles,GetAllMediaFilesSnacks/Services/AutoScanService.cs— scans video + music in one pass;[snacks]companion accepts both kindsSnacks/Services/ClusterDiscoveryService.cs— synthetic"music"device on every worker;SupportsMusic = trueSnacks/Controllers/LibraryController.cs— returns video + music withkindfieldSnacks/Controllers/DashboardController.cs/ClusterController.cs—?kind=filter on every dashboard endpointSnacks/Views/Shared/_MusicSettings.cshtml(new) — Music settings tab partialSnacks/Views/Shared/_AppModals.cshtml— Music tab; "Media Library Browser" renameSnacks/Views/Dashboard/Index.cshtml— All / Video / Music chip stripSnacks/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 +IsSyntheticDeviceSnacks/Services/Routing/VideoJobRouter.cs(new) — extracted scoringSnacks/Services/Routing/MusicJobRouter.cs(new)Snacks/Program.cs— DI registrations for both routers and the registrySnacks/Services/ClusterService.cs—_routersinjection,_scoreContext, per-kind scoring, per-kind output extension at completionSnacks/Services/ClusterNodeJobService.cs—_routers.For(kind).EncodeRemoteAsync(...); synthetic"music"device short-circuit inResolveDeviceId
Shared-storage mode
Snacks/Services/Cluster/SharedStoragePathValidator.cs(new) — canonicalise / resolve / allowlistSnacks/Models/MetadataAck.cs(new)Snacks/Models/ClusterConfig.cs—SharedStorageEnabled, allowlists, rewrite from/toSnacks/Models/NodeSettings.cs—DisableSharedStorageper-node opt-outSnacks/Models/JobMetadata.cs—SharedStorageInputPath/OutputPath/OutputDirectorySnacks/Controllers/ClusterController.cs— asyncRegisterMetadatawithMetadataAckreply;_shared.jsonsentinel; immediateStartAutonomousEncodingAsyncSnacks/Controllers/ClusterAdminController.cs— config payload includes shared-storage fieldsSnacks/Services/ClusterFileTransferService.cs—RegisterMetadataAsyncreturnsTask<MetadataAck>;TryParseAckdefensive parsingSnacks/Services/ClusterService.cs—_sharedJobOutputPathsmap; dispatch skips upload in shared mode; completion polls for shared output instead of downloadingSnacks/Services/ClusterNodeJobService.cs—TryReadSharedSentinel; atomic-rename to shared output; recovery short-circuit gated by sentinelSnacks/Views/Shared/_ClusterSettings.cshtml— Shared Storage panelSnacks/wwwroot/js/cluster/cluster-settings-form.js— load/save the new fieldsSnacks.Tests/Cluster/SharedStoragePathValidatorTests.cs(new)
Linux multi-GPU
Snacks/Services/TranscodingService.cs—EnumerateRenderNodes,GetDevicePathForDeviceId, path-awareGetInitFlags,ResolveHardwareAccelerationAsyncauto-fills path; vainfo logs the chosen nodeSnacks/Models/HardwareDevice.cs—DevicePathSnacks/Models/EncoderOptions.cs—HardwareDevicePathper-dispatchSnacks/entrypoint.sh— render-group walk across everyrenderD*Snacks.Tests/Video/HardwareEncoderTests.cs— path-aware overload pinsSnacks.Tests/Pipeline/FullCommandScenarioTests.cs— Scenario 7b end-to-end
Foreign-culture duration parsing
Snacks/Services/FfprobeService.cs—InvariantCultureforDurationStringToSecondsSnacks/Services/TranscodingService.cs— same fix in measured-bitrate parser and music duration probeSnacks.Tests/Video/DurationParsingCultureTests.cs(new)
Linux container PUID/PGID
Snacks/Dockerfile— pre-createsnacksUID 1000, installgosu, drop the Nobleubuntuuser, copyentrypoint.shSnacks/entrypoint.sh(new) —usermod+chown+gosuprivilege dropdocker-compose.yml— commented PUID/PGID env block
OCR keepalive
Snacks/Services/Ocr/NativeOcrService.cs— 5-minute poll-loop log line inAcquireSlotAsync
Master-local SlotLedger integration
Snacks/Services/TranscodingService.cs—SetSlotLedger,_localNodeIdstamping,TryReserveLocalDeviceSlotvia ledger,Releasein continuation;UpdateOptionswakes schedulerSnacks/Services/ClusterService.cs—UpdateLocalSelfNoderegisters master in_nodesfor ledger capacity; consolidatedReleaseActiveSlotinHandleNodeFailureAsync; ledger release on "source removed during encoding" path
Version bumps
Snacks/Controllers/HomeController.cs— health endpoint versionSnacks/Services/ClusterDiscoveryService.cs—ClusterVersionprotocol bump to 2.11.0Snacks/Views/Shared/_Layout.cshtml— footer version + window title renameREADME.md— badge, footer, "Media Library Transcoder" title, NVIDIA-in-Docker section, hybrid-GPU notebuild-and-export.bat— Docker tag versionelectron-app/package.json/package-lock.json— version + description rename to "Automated media transcoding"
Full documentation: README.md