Automated media library transcoder

Your complete guide to Snacks.

Install it, configure safe encoding rules, operate a multi-node cluster, automate it through the JSON API, and recover confidently when something goes wrong.

Orientation

What Snacks does

Snacks scans video and music libraries, evaluates each file against your target codec and bitrate rules, builds an FFmpeg command, validates the result, and keeps the output only when it satisfies the configured policy. It can run as a web container on a NAS, as an Electron desktop application, or as a distributed cluster.

DiscoverProbeDecideEncode / muxVerifyPlace output

Library-first

SQLite remembers file state across restarts, so large libraries do not start from zero after every deployment.

Hardware-aware

NVIDIA, Intel, AMD, Apple VideoToolbox, and CPU paths are selected according to platform and configured device slots.

Failure-tolerant

Retries, fallbacks, persistent logs, output verification, and cluster reassignment protect long-running sweeps.

Snacks needs write access to replace or place files. Test on a small directory first, keep a backup, and leave “Replace original files” disabled until you have validated playback in your own clients.
First run

Quick start

Docker / NAS

services:
  snacks:
    image: derekshreds/snacks-docker:latest
    container_name: snacks
    network_mode: host
    volumes:
      - /path/to/media:/app/work/uploads
      - /path/to/snacks/logs:/app/work/logs
      - /path/to/snacks/config:/app/work/config
    environment:
      - ASPNETCORE_ENVIRONMENT=Production
      - SNACKS_WORK_DIR=/app/work
      - FFMPEG_PATH=/usr/lib/jellyfin-ffmpeg/ffmpeg
      - FFPROBE_PATH=/usr/lib/jellyfin-ffmpeg/ffprobe
    restart: unless-stopped
  1. Replace the three host paths with real paths on the NAS.
  2. Add the appropriate GPU device/runtime configuration if using hardware acceleration.
  3. Start the compose application and open http://NAS-IP:6767.
  4. Open Settings, save a conservative preset, and process one test file.

Changing the port

Snacks configures its listener explicitly, so the generic ASP.NET variables (HTTP_PORTS, DOTNET_URLS) are ignored. How to move off 6767 depends on the network mode:

  • Host networking (the compose file above): Docker silently ignores ports: mappings under network_mode: host. Set the port through the one variable Snacks honors:
    environment:
      - ASPNETCORE_URLS=http://0.0.0.0:7070
    Keep the 0.0.0.0 host so the UI and cluster peers can reach it over the LAN.
  • Bridge networking: remove network_mode: host and map the host port onto the container's default instead — no environment variable needed:
    ports:
      - "7070:6767"
    Note that UDP broadcast discovery for clusters does not work across a bridge network; use manual node URLs, or stay on host networking.

Cluster nodes announce whatever port they actually bound, so a custom port is safe with discovery. The UDP discovery port 6768 itself is fixed.

Desktop

Install the Windows or Apple Silicon macOS build, launch Snacks, and use Browse Library. The desktop wrapper starts the ASP.NET backend locally and bundles or points it at FFmpeg. Cluster mode intentionally changes the backend from localhost-only to a LAN listener. There is no port to configure: the wrapper prefers 6767 and automatically starts on another free port when it is taken.

Recommended first-job checklist

Before

  • Use a copy of one representative file.
  • Choose a widely supported container and codec.
  • Keep original replacement off.
  • Confirm the detected encoder in the UI.

After

  • Check video, audio, subtitles, HDR, and seeking.
  • Compare source and output size.
  • Read the per-job FFmpeg log.
  • Only then expand to a small folder.
Workflow

Everyday use

Browse and enqueue

  1. Select Browse Library.
  2. Navigate to a folder and choose immediate files or recursive discovery.
  3. Optionally run Analyze first to preview Queue, Mux, and Skip decisions.
  4. Process selected files or the whole folder.

A manual process action is explicit: it can force a mux pass even when the source is already at the video target, allowing audio/subtitle rules and output-container normalization to be applied. Automatic scanning uses the normal eligibility rules.

Understand queue states

StateMeaningWhat happens next
PendingWaiting for a local or remote slot.Processed according to priority and queue policy.
UploadingSource is moving to a worker.Becomes Processing after transfer and verification.
ProcessingFFmpeg is running.Progress and log lines arrive through SignalR.
DownloadingCluster output is returning to the master.The master validates and places it.
CompletedValidated output was kept.Terminal.
NoSavingsOutput was valid but not worth keeping under the size policy.Terminal unless explicitly retried.
FailedRetry paths were exhausted.Inspect logs, adjust settings, then retry.
StoppedRemoved for now.May reappear on a later automatic scan.
CancelledExplicitly marked “do not reprocess.”Only a manual selection queues it again.

Stop, cancel, retry, prioritize

  • Stop means “encode later.”
  • Cancel is sticky against automatic reprocessing.
  • Retry resets selected failed items to pending.
  • Prioritize raises an item above the current maximum queue priority.
  • Pause is persisted and affects auto-scan, local processing, and cluster dispatch.
Encoding policy

Settings that matter most

AreaKey choicesGuidance
ModeTranscode, mux-only, or hybridHybrid is useful when track/container cleanup matters even for already-efficient video.
VideoH.264, HEVC, AV1; bitrate; hardware encoderChoose compatibility first. AV1 may be slow or unsupported on older playback devices.
ResolutionNever, cap at target, always; fixed frame; FPS capA fixed frame overrides normal downscale behavior and adds padding when required.
HDRPreserve or tone-map to SDRValidate color and brightness with real HDR sources before a batch.
AudioLanguages, original tracks, output profilesUse ISO language preferences and verify files with missing/incorrect language tags.
SubtitlesLanguages, sidecars, OCR, image pass-through, SDH exclusionPGS/VobSub OCR needs Tesseract data and is more expensive than text subtitle handling.
OutputContainer, output directory, scratch directory, original replacementScratch should have enough free space for source-scale temporary files.
ReliabilityRetry, log retention, deep-verification budgetUse a nonzero verification budget to continuously sample older library entries.
QueueBitrate-first or newest-firstNewest-first is best when recent downloads should jump ahead of a backlog.
Presets are the safest way to experiment. Save a known-good baseline, create a second preset for the experiment, and export important presets before a major upgrade.
Opt-in power controls

Advanced video policies and encoding profiles

Advanced Video is an optional layer under Settings → Video. It is off after upgrade, so existing settings, presets, watched folders, skip decisions, and FFmpeg behavior remain unchanged. Enable it only after creating and validating at least one profile. Audio, subtitles, container, paths, and file handling continue to come from the ordinary settings; profiles are deliberately video-only.

The panel is organized as three numbered steps that mirror how a decision actually happens: a decision flow of plain-language rule cards every video falls through top to bottom (the first match wins — visibly), the encoding recipes those rules pick (recipes are the UI name for video profiles), and a live library impact preview that runs the staged, unsaved flow against every video Snacks knows about and shows exactly how many files each rule catches and what would happen to them — before anything is applied.

Recipes are complete

A recipe (video profile) selects H.264, HEVC, or AV1; automatic or one exact detected encoder; bitrate, quality, or custom rate control; preset/speed, threads, pixel format, GOP, codec profile/level, transformations, extra filters, guarded options, and output retention. The editor shows the FFmpeg video arguments the recipe generates as you type.

Rules are predictable

Enabled rules run top to bottom and the first match wins. Each card reads as a sentence — “If the codec is not av1 and the resolution class is 2160p+ → encode with AV1 4K” — with a live count of the library files it catches. A rule uses a flat All or Any condition list; there are no scripts, expressions, or nested condition trees.

Library impact preview

The impact section is read-only: it never queues, re-evaluates, or saves anything. It applies the same shared resolver used by scanning and dispatch — including per-folder policies — to every tracked video and buckets the outcomes with example files, current disk usage, and how many files in each bucket were already processed, so a mistake is visible as a wrong-looking bar instead of a surprise batch of jobs. Bitrate recipes also show an approximate output size (target × duration); quality and custom recipes deliberately show no forecast, because their size genuinely depends on content. A search box answers "what happens to this file" by name, and once encodes complete, a Measured so far strip shows each recipe's real jobs, savings, and average output bitrate next to the forecast. Very large libraries are analyzed as a uniform random sample beyond 20,000 videos and labeled as such.

Decision flow with plain-language rule cards and live per-rule file counts

Library impact preview with counts, disk usage, and measured results

Applying is not reprocessing

Applying a policy only changes future decisions. Files already in the catalog keep their current status until Re-evaluate runs — the impact panel says so and offers the button right after an apply. Rules the flow can never reach are flagged inline ("never reached — an earlier rule always claims these files first") by a conservative static analysis that only reports what it can prove.

Sharing policies

A policy exports as a plain snacks-video-policy.json file and imports on any Snacks instance; imports arrive as a staged draft with fresh internal ids and nothing applies until Validate & Apply. The current draft can also be saved as a named template that appears alongside the built-in quick-start cards. Exact-encoder recipes may name any adapter-known encoder — including ones no connected node advertises yet, labeled "not detected yet" — so a policy can be written today for hardware that joins the cluster tomorrow.

Rate control and retention

  • Bitrate uses an absolute profile target with optional min, max, buffer, and strict/constrained behavior. The Simple 4K multiplier is not applied to a profile.
  • Quality maps to the encoder's native CRF, CQ, ICQ, global-quality, or quantizer controls. Selecting it defaults new edits to Always Keep, because final size is intentionally unpredictable.
  • Custom emits no generated rate-control arguments. Add the required encoder-private controls in the guarded option editor.
  • Smaller Only keeps the existing savings behavior. Always Keep retains any valid completed output, even when it is larger than its source.

Conditions and actions

Conditions can inspect normalized codec aliases, width, height, short-edge resolution class (SD, 720p, 1080p, 1440p, or 2160p+), video bitrate, file size, duration, pixel format, derived bit depth, HDR, and 4K. Unknown values match only IsUnknown; numeric ranges include both endpoints.

ActionBehavior
UseSimpleSettingsRuns the established codec/bitrate/filter/mux skip ladder unchanged.
TranscodeWithProfileForces video re-encoding with the complete selected profile, even when legacy bitrate checks would skip.
MuxOnlyCopies video and runs only applicable container, audio, and subtitle work; files with no mux work remain skipped.
SkipSkips the entire job, including mux-only work.

Exact encoders and clusters

The encoder picker is populated from the configured FFmpeg binary and connected workers. An exact encoder is never remapped or replaced by a retry fallback. If no local or protocol-compatible worker advertises that exact name on the assigned device, the job remains Pending and shows why. Older workers can still run Simple jobs, but cannot receive resolved Advanced plans.

Portable, not magical. A profile may be saved while its exact encoder is unavailable so it can move between installations. Validate the availability badges before queueing a large batch; Snacks will wait rather than silently substitute.

Guarded FFmpeg controls

Each custom row stores one option and at most one literal value token. Encoder-private controls such as -aom-params are allowed and appended after generated video settings so an intentional duplicate wins with a warning. Inputs, outputs, maps, muxers, audio/subtitle controls, progress/report controls, and replacement filtergraphs are rejected. Additional filters belong in the dedicated ordered single-chain field. FFmpeg is started with an argument vector, never through a shell; the displayed command is only a readable preview.

Quick-start templates

The fastest way in is the Quick start gallery at the top of the Advanced Video panel. Each template stages a complete working policy — profiles, rules, and default action — and immediately shows its validation result and the FFmpeg arguments it would generate. Nothing is saved until you press Validate & Apply, so a template is also a safe way to explore how profiles and rules fit together before building your own.

  • Convert everything to AV1 — one quality-based AV1 profile; already-AV1 sources are skipped.
  • Tiered AV1 (1080p + 4K) — separate quality levels selected by ordered resolution rules.
  • HEVC space saver — re-encodes older codecs to quality-based HEVC and leaves HEVC/AV1 alone.
  • Fine-tuned libaom-av1 (expert) — the complete community-requested archival policy below, including its guarded -aom-params rows. Requires an FFmpeg build with libaom.

Suggested AV1 setup

The expert template stages all of the following in one click; the same policy can be built by hand:

  1. Create a profile from the current Simple settings, choose AV1 and exact libaom-av1, then choose Quality.
  2. Set quality 35, speed 4, threads 8, yuv420p10le, GOP 300, and Always Keep.
  3. Add lag, ARNR, tune, and -aom-params as separate guarded rows; use Validate Preview to inspect exact tokens.
  4. Create a first rule for codec is not AV1 plus resolutionClass is 2160p+ and select a 4K profile.
  5. Create a later codec-not-AV1 rule for the 1080p profile. Analyze a test folder before processing it.

A complete importable block for that scenario lives at examples/advanced-video-policy.json. Full-app user presets include this block. Applying a built-in Simple preset disables Advanced Video without deleting its profiles or rules, so it can be turned back on later.

Folders, changes, and rollout

  • A watched folder can inherit global rules, force Simple behavior, or always select one global profile. Existing scalar overrides apply afterward.
  • A profile cannot be deleted while a rule or watched folder references it.
  • Analyze, scanning, manual processing, re-evaluation, local dispatch, and cluster dispatch share the same resolver. Rules are checked again immediately before dispatch.
  • Saving settings does not walk the full library. Use the existing Re-evaluate action when a policy change should update old catalog rows.
  • Invalid hand-edited configuration blocks affected work with a diagnostic; it never falls back to Simple without being asked.
What happens to a file

Processing, retries, and file safety

Snacks stages output separately from the source, validates the result, and only then performs final placement. Encoding never writes into the source file in place. Keep Replace original files disabled until the chosen settings have been tested with the playback clients and media types that matter to you.

Eligibility and smart filtering

  • In Simple behavior, files whose codec is already at least as efficient as the target can be skipped when they sit below the configured bitrate ceiling and tolerance.
  • Simple behavior applies the configured 2×–8× bitrate multiplier to 4K files. Advanced profile bitrates are absolute; choose a different profile through a rule instead.
  • Simple behavior does not re-encode AV1 down to HEVC or H.264, or HEVC down to H.264, merely to satisfy an exact codec-name match. An explicit Advanced profile action intentionally overrides that legacy guard.
  • Mux, audio, subtitle, container, crop, resolution, frame-rate, and HDR work can make an otherwise efficient file eligible for processing.
  • Analyze applies the same decision policy without adding work to the queue.
  • A filename already containing [snacks] is treated as previously produced output and is not automatically fed back into the pipeline.

Output names and placement

ConfigurationResult
No output directory; replacement offMovie [snacks].mkv is placed beside the original, which remains untouched.
Output directory; replacement offThe tagged output is kept in that directory and the original stays in place.
Scratch/encode directory configuredTemporary output is created there, then moved to the requested final destination after validation.
Replacement onThe validated output is moved into the source directory with the [snacks] tag removed; replacement happens only during final placement.

Sidecar files produced during processing move with the output. A stale tagged output left by an interrupted attempt is removed before FFmpeg starts again. If an ordinary encode is not smaller than its source, Snacks discards it as NoSavings; intentional remuxes, explicitly configured audio-output growth, and Advanced profiles set to AlwaysKeep are retained.

Video retry ladder

Retries are selected from the failure itself, so not every job uses every stage. The normal escalation order is:

  1. Retry unsupported hardware features with conservative encoder flags.
  2. For decoder/filter-graph failures, use software decode while preserving hardware encode where supported.
  3. Drop image-based subtitles while retaining text and successfully OCR-produced subtitles.
  4. Retry without subtitles if a broken subtitle stream still prevents completion.
  5. When Retry on failure is enabled, use the correct software encoder for the requested target codec.

Failed partial output is cleaned between attempts. When all retries are exhausted, the source remains unchanged and the final reason is stored in the job log and catalog. The music path is deliberately simpler: it validates duration and discards empty, invalid, or no-savings output instead of using the video retry ladder. Exact Advanced encoders may retry decoding/subtitle handling, but never enter the encoder-substitution step.

Background operation

Automatic scanning

Auto-scan keeps a persistent catalog, watches one or more folders, and periodically finds new or materially changed media. A file modified in the last 30 minutes is skipped to avoid reading it during a transfer.

A known path is treated as replaced media when its size changes by more than 10% or its duration changes by more than 30 seconds. It is then probed and evaluated as new work. Previously processed rows are retained in SQLite across restarts, failed files carry a failure count, and interrupted [snacks] output is cleaned before retrying.

  1. Add watched directories in the library browser or through the API.
  2. Set an interval in minutes and optional exclusion rules.
  3. Enable auto-scan; use Trigger to test immediately.
  4. Watch ScanProgress / AutoScanCompleted or the UI status.

Exclusions

  • filenamePatterns: glob-style patterns such as *REMUX* or sample?.mkv.
  • minSizeGBToSkip: skips files at or above the threshold.
  • excludeResolutions: labels such as 2160p, 1080p, or 720p.
“Clear history” is broad. It makes previously processed files eligible for discovery again. Prefer Re-evaluate after a settings change when that is your goal.
Distributed encoding

Clusters

A master owns the authoritative queue and catalog. Worker nodes advertise capabilities, accept assigned files, encode them, and return verified output. Major version compatibility is checked during discovery.

Master

  • Stores the queue and encode history.
  • Chooses a node/device slot per media kind.
  • Uploads sources and downloads results.
  • Can optionally encode locally.

Worker

  • Reports GPU/CPU capabilities and slot availability.
  • Uses a separate temporary job directory.
  • Receives settings and approved integration data.
  • Can be paused independently.

Discovery and networking

  • Web/API traffic defaults to TCP port 6767 (see Changing the port); nodes announce the port they actually bound.
  • Automatic discovery broadcasts on UDP port 6768 (fixed).
  • Host networking is normally required for Docker broadcast discovery.
  • Manual node URLs work when broadcast does not cross subnets.
  • Transfer concurrency, bandwidth, and chunk size are configurable on the master.
Use a strong shared secret and TLS on untrusted networks. The cluster header is Base64-encoded for transport, not encrypted. With HTTP, anyone able to observe the traffic can recover it.

Shared storage mode

Shared storage avoids source/output transfer when both sides can access the same share. Workers fail closed: configure explicit input and output allowlists. If mount points differ, add master-prefix → worker-prefix rewrites; the longest matching prefix wins. If either side cannot safely use the shared path, Snacks falls back to normal transfer.

Platform details

Hardware acceleration and containers

Encoder familyDocker / LinuxWindowsmacOS
NVIDIANVENC through CUDA runtimeNVENC
IntelVAAPIQSV
AMDVAAPIAMF
AppleVideoToolbox H.264/HEVC
Softwarex264, x265, SVT-AV1x264, x265, SVT-AV1x264, x265, SVT-AV1
  • Hardware detection runs automatically and tests real encoder availability rather than relying only on device names.
  • Linux detection checks every /dev/dri/renderD* node, so an Intel iGPU can still be found when a discrete GPU owns renderD128.
  • On older Intel/AMD devices, Snacks can decode an unsupported source in software and still encode through VAAPI.
  • QNAP commonly needs privileged: true because its video/render group layout differs from conventional Linux hosts. Unraid normally uses group access instead; follow unraid/README.md.
  • Intel Elkhart Lake devices such as the QNAP TS-453E use VAAPI constant-quality control rather than VBR.
  • Apple silicon can use VideoToolbox AV1 decode where available, but FFmpeg does not expose an AV1 VideoToolbox encoder; AV1 output therefore uses SVT-AV1 software encoding.

NVIDIA in Docker

Passing /dev/dri or enabling privileged mode is not enough for NVENC. Install the NVIDIA Container Toolkit on the host and configure the NVIDIA runtime:

runtime: nvidia
environment:
  - NVIDIA_VISIBLE_DEVICES=all
  - NVIDIA_DRIVER_CAPABILITIES=compute,video,utility

Healthcheck

Add this to the Docker service when the host should monitor application liveness:

healthcheck:
  test: ["CMD", "curl", "-f", "http://localhost:6767/api/health"]
  interval: 30s
  timeout: 10s
  retries: 3
  start_period: 40s

The maintained QNAP example is deploy-compose.yml. Typical media roots are /share/CACHEDEV1_DATA/Multimedia or /share/Public on QNAP and /volume1/video or /volume1/Media on Synology.

External systems

Integrations and notifications

IntegrationPurposeCredential
PlexConnection test and optional library rescan after completion.Server token
JellyfinConnection test and optional library rescan after completion.API key
Sonarr / RadarrMedia-manager connectivity and metadata workflow support.API key
TheTVDB / TMDbOriginal-language and metadata lookups used by language-aware processing.API key; TVDB PIN optional
HomarrCompact Snacks iFrame tile, or Homarr's native Media Transcoding widget through a read-only Tdarr adapter.Scoped iframe token or Snacks API key
NotificationsWebhook, ntfy, or Apprise destinations for encode/scan/node events.Destination-specific

Integration credentials are stored in plaintext JSON inside the Snacks config directory. Protect that directory with host permissions, enable control-panel authentication, and restrict network access to trusted clients.

Homarr dashboards: choose a setup

Both choices are read-only and expose the same Snacks queue and cluster state, but they use different presentation, credentials, and network paths. They may be used together.

OptionChoose it forWho connects to SnacksCredential
Snacks compact tileSnacks' responsive Stats, Queue, and Workers views inside a Homarr iFrame widget.Each viewer's browserScoped iframe URL when Snacks sign-in is enabled
Homarr Media TranscodingHomarr's native widget, backed by Snacks' Tdarr-compatible read model.The Homarr server or containerSnacks API key when Snacks sign-in is enabled

Snacks compact tile (Homarr iFrame)

The compact tile is a server-rendered Snacks page at /iframe/homarr. Its Stats tab shows lifetime space savings, encode counts, current queue counts, a 14-day savings sparkline, and active work. Queue shows active and pending files; Workers shows the local instance and discovered cluster nodes. Nothing in the tile can mutate Snacks.

  1. Open Snacks Settings → Security → Iframe Access. Add the exact Homarr origin seen in the browser, including its scheme and non-default port, such as https://homarr.example.com or http://192.168.1.20:7575. Enter an origin, not a board path, then select Save origins.
  2. Select Generate URL and copy the resulting URL. Generating again replaces the previous iframe token; Revoke invalidates it immediately.
  3. Open the desired Homarr board in edit mode, add an iFrame widget, and paste the copied value into Embed URL. Disabling widget scrolling is optional because the tile is responsive.
  4. Test the board from a normal viewer device. An iFrame is loaded by that device's browser, so its Snacks URL must be resolvable and reachable there; a Docker-only name such as snacks usually is not. If necessary, replace only the generated URL's scheme, host, and port with the browser-reachable Snacks origin.

The generated URL can be customized by appending these query parameters:

ParameterValues and defaultEffect
embedTokenGenerated snk_embed_… valueRequired by the iframe route when the Snacks sign-in gate is active. It is retained when changing tile tabs.
themedark (default) or lightSelects the tile color scheme.
tabstats (default), queue, or workersSelects the initial tab; visitors can still switch tabs in the tile.
limit10 by default; clamped to 1–30Limits rows shown on the Queue tab.
refresh30 seconds by default; 0 disables, other values clamp to 10–3600Reloads the server-rendered snapshot.
https://snacks.example.com/iframe/homarr?embedToken=YOUR_TOKEN&theme=dark&tab=stats&limit=10&refresh=30
Treat the generated URL as a secret even though its token is read-only: the tile may reveal media filenames. The allowed-origin list is an additional browser framing policy, not a substitute for the token. Snacks always permits same-origin framing; an empty list permits only same-origin framing. An HTTPS Homarr board also cannot embed an HTTP Snacks URL because browsers block mixed content.

Homarr Media Transcoding widget (Tdarr adapter)

Snacks implements only the read-only Tdarr API subset consumed by Homarr's native Media Transcoding widget. Use this choice when the widget should inherit Homarr's own design, default-view setting, and queue pagination.

  1. If Snacks sign-in is enabled, open Settings → Security → API Access and generate or copy a Snacks API key. If sign-in is disabled, Homarr's No Authentication option also works.
  2. In Homarr, open integration management and create a Tdarr integration. Give it any descriptive name.
  3. Set URL to the Snacks base origin only, such as http://snacks:6767 or http://192.168.1.20:6767. Do not append /api or an endpoint path. This address must be reachable from the Homarr server or container, not merely from the browser.
  4. Choose API Key and paste the Snacks key when authentication is active; otherwise choose No Authentication. Test and create the integration.
  5. Edit a Homarr board, add a Media Transcoding widget, select the new integration, then choose its default view and a queue page size from 1–30.
Homarr tabSnacks dataIntentional limitations
StatisticsCatalog and lifetime encode totals, saved space, outcomes, current queue counts, 365-day video/music codec mix, and 4K split.Health-check and container-history charts are empty because Snacks does not retain those Tdarr concepts.
QueueCurrent active and SQLite-backed pending transcodes, with source container, codec, resolution, size, and status.Read-only; completed history and Tdarr health-check table are not exposed here.
WorkersLocal and discovered cluster nodes, pause state, active file, device, phase, and progress.FPS, ETA, and worker size estimates are not currently available and display neutral values.
Point the integration at the Snacks master/coordinator for a cluster-wide view. In a shared Docker network, http://snacks:6767 can work here because Homarr makes the requests; localhost refers to Homarr itself. The adapter cannot execute Tdarr plugins, control jobs, or act as a complete Tdarr server. Although its calls are read-only, a Snacks API key is a full API credential and must be protected accordingly.

Homarr's current field names and widget options are also described in its official Tdarr integration, Media Transcoding widget, and iFrame widget documentation.

Persistence

Data, configuration, and backups

SNACKS_WORK_DIR selects the work root. In the standard container it is /app/work; desktop defaults under the current user's local application data.

Path under work directoryContentsBack up?
config/snacks.dbSQLite catalog, status, transitions, and encode history.Yes
config/settings.jsonGlobal encoder options.Yes
config/presets.jsonNamed encoder presets.Yes
config/autoscan.jsonWatch folders, interval, pause state, exclusions.Yes
config/cluster.jsonRole, identity, shared secret, discovery, shared paths.Yes; sensitive
config/node-settings.jsonPer-node devices, schedules, and limits.Yes
config/integrations.jsonThird-party endpoints and credentials.Yes; sensitive
config/notifications.jsonNotification destinations and event toggles.Yes; sensitive
config/auth.jsonPassword hash and session signing secret.Yes; sensitive
config/networking.jsonCluster transfer limits and chunk size.Yes
logs/Rolling application log and per-encode FFmpeg logs.Optional
remote-jobs/Worker transfer and encode scratch space.No

Backup procedure

  1. Pause the queue and wait for active work to finish.
  2. Stop Snacks so SQLite WAL state and config writes are quiescent.
  3. Copy the complete config/ directory as one unit.
  4. Restart Snacks and verify /api/health.

JSON configuration writes are atomic and keep .bak fallbacks, but a host-level backup remains the recovery boundary for accidental deletion or disk loss.

Environment-variable overrides

Encoder, auto-scan, and integration settings can be pinned from the environment — handy for version-controlling configuration in a compose file. Overrides are applied in-memory on every load and never written to the JSON files, so removing a variable reverts to the file's value. Env-pinned settings show a lock icon in the GUI.

  • SNACKS_SET_<Prop>settings.json, nested properties via __ (e.g. SNACKS_SET_Music__BitrateKbps).
  • SNACKS_SCAN_<Prop>autoscan.json.
  • SNACKS_INTEG_<Section>__<Prop>integrations.json.

Property names are case-insensitive. Booleans accept true/false/1/0/yes/no/on/off; numbers and enums accept their plain values; string lists accept comma-separated or JSON values; nested objects and other complex values accept JSON. An invalid or unknown variable logs a warning once and is skipped — startup never fails on a bad override.

environment:
  - SNACKS_API_KEY=change-me
  - SNACKS_SET_Codec=av1
  - SNACKS_SET_TargetBitrate=2500
  - SNACKS_SET_Music__BitrateKbps=256
  - SNACKS_SET_AudioLanguagesToKeep=en,ja
  - 'SNACKS_SET_AudioOutputs=[{"Codec":"aac","Layout":"Stereo","BitrateKbps":192}]'
  - SNACKS_SET_AdvancedVideo__Enabled=true
  - 'SNACKS_SET_AdvancedVideo__Profiles=[{"Id":"11111111-1111-4111-8111-111111111111","Name":"AV1 CQ 35","Codec":"av1","EncoderSelection":"Explicit","Encoder":"libaom-av1","RateControl":{"Mode":"Quality","Quality":35},"Preset":"4","Threads":8,"PixelFormat":"yuv420p10le","GopSize":300,"OutputRetention":"AlwaysKeep"}]'
  - 'SNACKS_SET_AdvancedVideo__Rules=[{"Id":"22222222-2222-4222-8222-222222222222","Name":"Codec is not AV1","Match":"All","Conditions":[{"Field":"Codec","Operator":"IsNot","Values":["av1"]}],"Action":"TranscodeWithProfile","ProfileId":"11111111-1111-4111-8111-111111111111"}]'
  - 'SNACKS_SCAN_Directories=["/media/tv","/media/movies"]'
  - SNACKS_SCAN_Enabled=true
  - SNACKS_SCAN_IntervalMinutes=30
  - SNACKS_INTEG_Plex__BaseUrl=http://plex:32400
  - SNACKS_INTEG_Plex__Token=replace-me
  - SNACKS_INTEG_Plex__RescanOnComplete=true
  - SNACKS_INTEG_Plex__Enabled=true

Environment values win on every load, but are never copied into the JSON files. HardwareDevicePath is dispatch-specific and cannot be pinned. Runtime scan state—QueuePaused, LastScanTime, and LastScanNewFiles—is also excluded; control pause state through the queue API. On a worker, settings sent with a master-assigned job are authoritative, so node-local SNACKS_SET_* values affect only work queued by that node itself.

Operate it

Logs, diagnostics, health, and metrics

  • GET /api/health is an unauthenticated liveness check.
  • GET /metrics exposes Prometheus text and is cached for 15 seconds.
  • GET /api/diagnostics/log?lines=200 returns the latest application-log tail.
  • GET /api/diagnostics/logs.zip downloads available logs.
  • The Cluster Logs page can select a remote node; requests are proxied using the cluster secret.
# Liveness
curl -fsS http://snacks-host:6767/api/health

# Prometheus scrape
curl -fsS http://snacks-host:6767/metrics

# Authenticated local log tail
curl -b snacks.cookies \
  "http://snacks-host:6767/api/diagnostics/log?lines=300"
Recovery

Troubleshooting

The library browser is empty
Confirm the media mount exists inside the container and that the process can read it:
docker exec snacks ls -la /app/work/uploads
In container mode, paths outside the configured upload root are rejected by the API.
Hardware acceleration is missing
Check driver availability and device passthrough. On Linux:
docker exec snacks ls -la /dev/dri
docker exec snacks vainfo
NVIDIA also requires the NVIDIA container runtime and its video capability; privileged mode alone does not provide CUDA/NVENC libraries.
Port 6767 is already in use, or the port will not change

A startup failure like Failed to bind to address http://[::]:6767: address already in use means another process owns the port. Warnings such as Overriding HTTP_PORTS… or Overriding address(es)… Binding to endpoints defined via IConfiguration and/or UseKestrel() instead mean a port was supplied through a variable Snacks does not honor — it configures its listener explicitly, and only ASPNETCORE_URLS is read.

With Docker host networking, ports: mappings are silently ignored; set ASPNETCORE_URLS=http://0.0.0.0:7070 instead. With bridge networking, map "7070:6767" and leave the environment alone. The desktop app resolves port conflicts automatically. See Changing the port.

An output is larger than its source
Hardware quality modes are not exact bitrate guarantees. Snacks discards an output that is not smaller unless a mux or configured audio-output rule justifies growth. Lower the target, choose a different rate-control path, or inspect the command log.
The queue does not move
Check the persisted pause state, device-slot schedules, worker reachability, free scratch space, and the latest operations log. For a cluster, verify shared-secret and major-version compatibility on every node.
Progress is not updating
Confirm the SignalR connection indicator is green. When UI authentication is enabled, the hub requires the same valid session cookie as the JSON API. Check proxy WebSocket support if Snacks is behind a reverse proxy.
A file keeps failing
Open its FFmpeg log, verify the source with Library Health, and test the command's decode/encoder assumptions. Retry only after changing the cause; clearing broad history can create a much larger queue than intended.
Homarr rejects the Snacks Tdarr integration

Confirm that the integration URL is the Snacks base origin with no API path, that it points to the master/coordinator, and that the Homarr container can resolve and reach it. Use API Key with a current Snacks key when sign-in is enabled, or No Authentication only when the Snacks sign-in gate is disabled. Test the same probe from Homarr's network namespace when possible:

curl -i -X POST \
  -H "X-Api-Key: YOUR_SNACKS_API_KEY" \
  http://snacks:6767/api/v2/is-server-alive

HTTP 401 indicates a missing or stale key. A DNS or connection error indicates the Homarr-to-Snacks network path. For HTTPS, also verify the certificate trust chain. See Homarr Media Transcoding setup.

The Snacks tile is blank, refused, or unauthorized in Homarr

First open the generated iframe URL directly on the same viewer device. HTTP 401 means the scoped token was omitted, revoked, or replaced. If the URL works directly but not inside Homarr, add the exact Homarr origin—including scheme and port—to Settings → Security → Iframe Access. Also check that a reverse proxy is not replacing Snacks' Content-Security-Policy: frame-ancestors … response and that an HTTPS board is not trying to frame an HTTP URL.

A browser DNS or connection error means the generated Snacks origin is not reachable from the viewer; unlike an integration, an iFrame is not fetched by the Homarr container. See Snacks compact tile setup.

Authentication configuration is unusable
Stop Snacks, back up the config directory, and remove only config/auth.json. On the next start, authentication returns to its default disabled state and a new session secret is generated.
Automation

Public API basics

The web UI uses the same JSON endpoints documented below. Paths are relative to the running instance, for example http://snacks-host:6767. JSON properties use camelCase; incoming property names are case-insensitive. Dates are UTC ISO 8601.

Compatibility note. The API is not URL-versioned. Treat this reference as the v2.17.0 contract and test automation against new Snacks releases before deployment.

The running app also publishes a generated, machine-readable OpenAPI document at /openapi/v1.json. It contains the supported public/UI API and intentionally excludes the internal cluster RPC protocol.

Authentication

With control-panel authentication disabled, ordinary API routes require no credential. When enabled, sign in through the form endpoint and retain the snacks_session cookie. The cookie is HTTP-only and signed; its embedded token expires after 14 days (the browser cookie itself is session-scoped).

BASE="http://snacks-host:6767"

curl -fsS -c snacks.cookies \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "username=admin" \
  --data-urlencode "password=your-password" \
  --data-urlencode "returnUrl=/" \
  "$BASE/Auth/Login"

curl -fsS -b snacks.cookies "$BASE/api/queue/stats"

/api/health, /metrics, static assets, the login page, and secret-authenticated cluster RPC are intentionally outside cookie authentication. SignalR is protected whenever UI authentication is enabled.

For callers that can't do cookie login, /api/* also accepts an API key — sent as an X-Api-Key header or an Authorization: Bearer token. Generate one in Settings → Security → API Access (or via POST /api/auth/apikey/generate), or supply one through the SNACKS_API_KEY environment variable; both work at once, and the env-supplied key is never revealed by the API.

The Sonarr-style ?apiKey= query form is restricted to the read-only /api/v1/* and Homarr/Tdarr compatibility routes because URL credentials can leak into history and proxy logs. Mutation routes require a header, bearer token, or signed session. Iframe pages use a separate scoped ?embedToken= credential.

curl -fsS -H "X-Api-Key: $SNACKS_API_KEY" "$BASE/api/queue/stats"

Conventions

  • Send Content-Type: application/json for JSON request bodies.
  • Success is generally JSON with either the requested object or { "success": true }.
  • Validation failures normally use HTTP 400; missing items use 404; active analysis conflicts use 409.
  • An expired/missing UI session receives HTTP 401 for /api/*.
  • Destructive endpoints are marked destructive below.
  • File paths refer to the Snacks host filesystem, not the API client's filesystem.
Recipes

API examples

Inspect and pause the queue

curl -fsS -b snacks.cookies \
  "$BASE/api/queue/items?status=Pending&skip=0&limit=50"

curl -fsS -b snacks.cookies \
  -H "Content-Type: application/json" \
  -d '{"paused":true}' \
  "$BASE/api/queue/paused"

Queue one file with current settings

curl -fsS -b snacks.cookies "$BASE/api/settings" > settings.json

jq -n \
  --arg filePath "/app/work/uploads/Movies/Movie.mkv" \
  --slurpfile options settings.json \
  '{filePath:$filePath, options:$options[0]}' > request.json

curl -fsS -b snacks.cookies \
  -H "Content-Type: application/json" \
  --data-binary @request.json \
  "$BASE/api/library/process-file"

Analyze a directory without queueing

jq -n \
  --arg directoryPath "/app/work/uploads/Movies" \
  --slurpfile options settings.json \
  '{directoryPath:$directoryPath, recursive:true, options:$options[0]}' > analyze.json

JOB_ID=$(curl -fsS -b snacks.cookies \
  -H "Content-Type: application/json" \
  --data-binary @analyze.json \
  "$BASE/api/library/analyze-directory" | jq -r .jobId)

curl -fsS -b snacks.cookies "$BASE/api/library/analyze-status/$JOB_ID"
curl -fsS -b snacks.cookies "$BASE/api/library/analyze-results/$JOB_ID"

Update settings safely

# Read-modify-write so unknown/newer fields are preserved.
curl -fsS -b snacks.cookies "$BASE/api/settings" |
  jq '.targetBitrate = 3000 | .queueNewestFirst = true' > settings.updated.json

curl -fsS -b snacks.cookies \
  -H "Content-Type: application/json" \
  --data-binary @settings.updated.json \
  "$BASE/api/settings"
Endpoint reference

Library API

No API endpoints match the current filter.

MethodPathInputsPurpose / response
GET/api/library/directoriesBrowsable roots with names and media counts.
GET/api/library/subdirectoriesdirectoryPathImmediate allowed child directories.
GET/api/library/filesdirectoryPath, recursive=true, skip=0, limit=1000 (max 5000){files,total,videoTotal,musicTotal,truncated}.
POST/api/library/process-file{filePath, options}Queues one file as an explicit/manual job.
POST/api/library/process-directory{directoryPath, recursive, options}Queues allowed media under a directory.
POST/api/library/analyze-directory{directoryPath, recursive, options}Starts dry-run analysis; returns {success,jobId}.
GET/api/library/analyze-status/{jobId}Path ID{state,processed,total,error}; total is −1 while enumerating.
GET/api/library/analyze-results/{jobId}Path IDResults, summary, total, and truncation state; 409 while running.
POST/api/library/analyze-cancel/{jobId}Path IDCancels an active analysis job.
GET/api/library/healthfilter, q, skip=0, limit=100{items,total,summary}; issue filters include no-audio, no-video, no-duration, failed, verify-failed.
GET/api/library/insightsLibrary totals plus codec, resolution, and status distributions.
POST/api/library/health/verify{filePath}Runs bounded FFmpeg decode samples; returns {ok,issues}.
POST/api/library/health/reset-verify-file{filePath}Clears one failed-verification flag without deleting the file.
POST/api/library/health/reset-verify{filter,q}Bulk-clears verification flags matching the current health view.
POST/api/library/health/delete{filePath}Destructive: deletes a flagged file and its catalog row.
POST/api/library/health/delete-all{filter,q}Destructive: bulk-deletes matching flagged files; returns deleted/failed/capped counts.
Endpoint reference

Queue API

No API endpoints match the current filter.

MethodPathInputsPurpose / response
GET/api/queue/itemslimit, skip=0, statusPaginated non-active queue plus all active items and totals.
GET/api/queue/statsPending, active, completed, failed, and related aggregate counts.
GET/api/queue/item/{id}Work item IDOne in-memory work item or 404.
GET/api/queue/logs/{id}Work item IDAvailable per-job log lines.
POST/api/queue/prioritize/{id}Work item IDMoves a pending item to the front using queue priority.
POST/api/queue/stop/{id}Work item IDStops active work and marks it eligible for later reprocessing.
POST/api/queue/cancel/{id}Work item IDStops/cancels and prevents automatic reprocessing.
POST/api/queue/retry{filePath}Retries one failed file under the current encoder settings.
GET/api/queue/failedReturns failed queue entries.
DELETE/api/queue/failedDestructive to history: removes all failed entries.
GET/api/queue/pausedReturns the persisted pause state.
POST/api/queue/paused{paused:true|false}Pauses/resumes the processing system.
Endpoint reference

Configuration API

No API endpoints match the current filter.

Encoder settings and presets

MethodPathInputsPurpose / response
GET/api/settingsFull effective EncoderOptions JSON.
POST/api/settingsSettings objectSaves settings atomically and applies presence-aware migration.
POST/api/settings/reevaluateforceRetryNoSavings=falseRe-evaluates catalog eligibility using current settings.
GET/api/settings/video-encodersrefresh=falseRuntime H.264/HEVC/AV1 encoder catalog, native rate controls, local devices, and connected-worker availability.
POST/api/settings/advanced-video/validate{advancedVideo, profileId?, sourceFacts?}Validates staged profiles/rules and returns stable diagnostics plus a literal FFmpeg video-argument preview without executing it.
POST/api/settings/advanced-video/impact{advancedVideo}Read-only library impact preview for a staged policy, including decision buckets and rule matches.
GET/api/settings/advanced-video/measuredMeasured encode-history outcomes grouped by Advanced Video profile.
GET/api/settings/advanced-video/templatesLists user-saved Advanced Video policy templates.
POST/api/settings/advanced-video/templates{name,advancedVideo}Validates and upserts a named policy template (maximum 20).
DELETE/api/settings/advanced-video/templates/{name}Template nameDeletes one Advanced Video policy template.
GET/api/settings/presetsLists saved preset names and values.
POST/api/settings/presets{name, options}Creates or replaces a named preset.
DELETE/api/settings/presets/{name}Preset nameDeletes one preset.
GET/api/settings/presets/export/{name}Preset nameDownloads one preset as JSON.
POST/api/settings/presets/importExported preset JSONValidates and imports a preset.

Auto-scan

MethodPathInputsPurpose / response
GET/api/auto-scan/configSchedule, watch folders, last scan, pause state, exclusions.
POST/api/auto-scan/enabled{enabled}Enables/disables scheduled scans.
POST/api/auto-scan/interval{intervalMinutes}Sets the scan interval.
POST/api/auto-scan/directories{path}Adds an allowed watch directory.
DELETE/api/auto-scan/directories{path}Removes a watch directory.
POST/api/auto-scan/triggerStarts an immediate background scan.
POST/api/auto-scan/clear-historyBroad reset: clears persisted processing history.
GET/api/auto-scan/exclusionsReturns current ExclusionRules.
POST/api/auto-scan/exclusions{filenamePatterns,minSizeGBToSkip,excludeResolutions}Saves exclusion rules.

Auth, notifications, integrations, and networking

MethodPathInputsPurpose / response
GET/api/auth/configReturns only enabled, username, and hasPassword.
POST/api/auth/config{enabled,username,password}Saves auth; null/empty password preserves the existing hash. Clears the current cookie.
GET/api/auth/apikeyReturns the stored API key. A key supplied via SNACKS_API_KEY is never exposed here.
POST/api/auth/apikey/generateGenerates and persists a new stored API key, replacing any previous one.
DELETE/api/auth/apikeyRemoves the stored API key. A key set via SNACKS_API_KEY stays valid.
GET/api/auth/embedReturns the stored iframe-only token and concrete CSP origin allowlist.
POST/api/auth/embed/generateGenerates a new token accepted only by read-only /iframe/* pages.
DELETE/api/auth/embedRevokes the iframe-only token.
POST/api/auth/embed/origins{origins:["https://homarr.example"]}Replaces and normalizes the HTTP(S) origins allowed by iframe CSP.
GET/api/notifications/configFull destination and event-toggle configuration.
POST/api/notifications/configNotification configSaves destinations and toggles.
POST/api/notifications/testOne destination objectSends a test notification.
GET/api/integrations/configReturns Plex, Jellyfin, Sonarr, Radarr, TVDB, and TMDb configuration.
POST/api/integrations/configIntegration configSaves third-party configuration.
POST/api/integrations/test/plex{baseUrl,token}Tests Plex credentials/connectivity without saving.
POST/api/integrations/test/jellyfin{baseUrl,token}Tests Jellyfin credentials/connectivity without saving.
POST/api/integrations/test/sonarr{baseUrl,apiKey}Tests Sonarr credentials/connectivity without saving.
POST/api/integrations/test/radarr{baseUrl,apiKey}Tests Radarr credentials/connectivity without saving.
POST/api/integrations/test/tvdb{apiKey,pin}Tests TVDB authentication without saving.
POST/api/integrations/test/tmdb{apiKey}Tests TMDb authentication without saving.
GET/api/networkingReturns cluster transfer concurrency, rate, and chunk settings.
POST/api/networkingNetworkingSettingsSaves validated master-side transfer limits.
Endpoint reference

Dashboard, cluster administration, and diagnostics

No API endpoints match the current filter.

Dashboard analytics

Dashboard GET routes accept optional kind=video|music. Windowed routes clamp days to 1–365.

MethodPathInputsPurpose
GET/api/dashboard/summarykindLifetime hero totals.
GET/api/dashboard/savings-over-timedays=30, kindContinuous daily savings series.
GET/api/dashboard/device-utilizationdays=30, kindPer-device work totals.
GET/api/dashboard/codec-mixdays=30, kindOutput codec distribution.
GET/api/dashboard/node-throughputdays=30, kindPer-node leaderboard.
GET/api/dashboard/recentlimit=25, kindRecent successful encodes.
GET/api/dashboard/top-savingslimit=10, days=365, kindLargest compression wins.
DELETE/api/dashboard/historyDestructive to history: deletes the complete encode-history ledger.

Read-only dashboard and Homarr compatibility

The v1 routes are Snacks-native read models. The v2 routes intentionally reproduce only the case-sensitive Tdarr response shapes consumed by Homarr. Although three Tdarr probes use POST, every compatibility action is read-only. When sign-in is enabled these routes accept the API key as X-Api-Key, a bearer token, or the compatibility-only ?apiKey= query parameter. See the setup and supported data before treating this subset as a general Tdarr API.

MethodPathInputsPurpose
GET/api/v1/system/statusVersion, instance, runtime, role, node, uptime, and auth status.
GET/api/v1/statsLifetime history plus DB-backed current queue statistics.
GET/api/v1/queuepage=1, pageSize=10 (max 100)Active jobs, the complete SQLite pending queue, and recent terminal records.
GET/api/v1/workersDeduplicated local and cluster worker/job snapshots.
POST/api/v2/is-server-aliveHomarr Tdarr-adapter connection probe.
POST/api/v2/stats/get-pies{data:{libraryId:""}}Tdarr-shaped queue, history, codec, resolution, and saved-space statistics.
GET/api/v2/get-nodesTdarr-shaped node and active-worker dictionaries.
POST/api/v2/client/status-tables{data:{start,pageSize,opts:{table}}}table1 is the DB-backed transcode queue; unsupported health-check table4 is empty.

Cluster administration

MethodPathInputsPurpose
GET/api/cluster-admin/configCluster role, identity, discovery, secret, timing, and shared-storage config.
POST/api/cluster-admin/configClusterConfigSaves and applies cluster configuration.
GET/api/cluster-admin/statusLocal state, version, nodes, schedules, and live cluster status.
GET/api/cluster-admin/workersConnected/discovered worker list.
POST/api/cluster-admin/node-paused{nodeId,paused}Pauses/resumes one worker.
POST/api/cluster-admin/local-encoding-paused{paused}Pauses/resumes encoding on the master itself.
GET/api/cluster-admin/node-settingsPer-node devices, slot limits, and schedules.
POST/api/cluster-admin/node-settingsNodeSettingsSaves settings for one node.
DELETE/api/cluster-admin/node-settings{nodeId}Removes stored settings for a node.
POST/api/cluster-admin/folder-settings{path, encodingOverrides}Saves or clears per-folder encoder overrides for a watched folder.
GET/api/cluster-admin/master-timeReturns master time for schedule diagnostics.
GET/api/cluster-admin/integration-syncShows worker integration-sync state.
POST/api/cluster-admin/integration-sync/refreshForces an integration-data refresh.

Health, diagnostics, and lifecycle

MethodPathInputsPurpose
GET/api/healthUnauthenticated liveness JSON with UTC timestamp and version.
GET/metricsUnauthenticated Prometheus text exposition.
GET/api/diagnostics/loglines=200 (1–5000), optional nodeIdLatest operation-log tail, local or proxied.
GET/api/diagnostics/logs.zipOptional nodeIdDownloads local or remote logs as ZIP.
POST/api/restartInterruptive: stops active work, clears the queue process, and exits for host restart.
Realtime API

SignalR events

Connect an ASP.NET Core SignalR client to /transcodingHub. Browser clients automatically send the Snacks session cookie. The hub supports JoinGroupAsync(groupName) and LeaveGroupAsync(groupName); current application broadcasts are predominantly global.

EventTypical argumentsUse
WorkItemUpdatedWork item objectStatus, progress, transfer, assignment, completion updates.
WorkItemRemovedWork item IDRemove an item from the live UI.
QueueChangedNoneRefetch paginated queue state.
TranscodingLogWork item ID, lineAppend a live FFmpeg/log line.
HardwareDetectedHardware descriptorRefresh encoder/device UI.
ScanProgressProgress objectUpdate background scan progress.
AutoScanCompletedNew-file count, total seenRefresh scan summary and queue.
HistoryClearedNoneRefresh catalog/queue after auto-scan history reset.
EncodeHistoryAddedEncode-history recordRefresh dashboard analytics.
EncodeHistoryClearedNoneClear dashboard client state.
WorkerConnectedCluster nodeAdd a worker to cluster status.
WorkerUpdatedCluster nodeUpdate node health, slots, and activity.
WorkerDisconnectedNode IDMark/remove a disconnected worker.
ClusterConfigChangedConfig-change summaryRefresh cluster mode UI.
NodeSettingsChangedNode settingsRefresh device/schedule state.
ClusterNodePausedBooleanUpdate worker pause state.
ClusterWarningWarning messageSurface dispatch/network warnings.
const connection = new signalR.HubConnectionBuilder()
  .withUrl("/transcodingHub")
  .withAutomaticReconnect()
  .build();

connection.on("WorkItemUpdated", item => console.log(item.id, item.status, item.progress));
connection.on("QueueChanged", () => refreshQueue());
await connection.start();
Internal protocol

Cluster-internal API

Not a general automation surface. Routes under /api/cluster/* coordinate trusted Snacks nodes and may change with cluster protocol changes. Prefer /api/cluster-admin/* for administrative automation.

Every cluster-internal request requires X-Snacks-Secret: Base64(UTF-8(shared secret)). Credential-sync routes add LAN-source and active-node checks. The protocol covers:

  • Handshake, heartbeat, capabilities, node registry, and cluster state.
  • Job metadata, progress, completion, failure, cancellation, pause, and shutdown.
  • Chunked source upload, HEAD status/resume, output download, sidecars, and cleanup.
  • Dashboard and diagnostics mirrors used when a worker proxies to the master.
SECRET_HEADER=$(printf '%s' "$SNACKS_SHARED_SECRET" | base64 | tr -d '\n')

curl -fsS \
  -H "X-Snacks-Secret: $SECRET_HEADER" \
  "$BASE/api/cluster/heartbeat"
Contributing

Development and verification

The backend targets .NET 10 and serves Razor views, JSON controllers, static ES modules, and SignalR. Electron 43 wraps the published backend for desktop releases.

# Build and run the unit/integration test suite
dotnet build Snacks.sln --configuration Release
dotnet test Snacks.sln --configuration Release --no-build --verbosity minimal

# Run the web backend
dotnet run --project Snacks/Snacks.csproj

# Validate Electron, browser modules, API docs, and version synchronization
npm --prefix electron-app ci
npm --prefix electron-app run check

# Validate a generated OpenAPI document from a running backend
node scripts/validate-openapi.mjs http://localhost:6767/openapi/v1.json

# After changing the Version element in Snacks/Snacks.csproj
node scripts/sync-version.mjs

# Run documented multi-process scenarios (see e2e/README.md)
./e2e/scenarios/01-sweep-memory.sh
Building a desktop package or publishing a release? The source repository includes docs/BUILDING.md, which is the maintained guide for prerequisites, FFmpeg staging, Windows signing, macOS dylib bundling and notarization, Docker publishing, versioning, E2E tests, and the release checklist. Open it on GitHub.

Desktop packaging summary

Windows x64

  1. Install .NET 10 and Node.js 22 or newer.
  2. Place ffmpeg.exe and ffprobe.exe in electron-app/ffmpeg/.
  3. Run run-electron-dev.bat for development or build-installer.bat for NSIS output.
  4. Optional signing reads signing/snacks-signing.pfx and the gitignored signing/password.txt.

Apple silicon macOS

  1. Install .NET 10, Node.js 22+, Xcode tools, and brew install ffmpeg tesseract leptonica.
  2. Copy the Homebrew FFmpeg and FFprobe binaries into electron-app/ffmpeg/.
  3. Run ./build-mac.sh; it bundles non-system FFmpeg/OCR dylibs and produces a self-contained DMG.
  4. Optional distribution signing uses CSC_NAME, APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, and APPLE_TEAM_ID from electron-app/.env.mac.local.

The Windows installer and macOS DMG are written beneath electron-app/dist/. Never commit signing certificates, passwords, or Apple credentials. The Docker build-and-export.bat script pushes two public image names and requires an explicit confirmation; use a normal local docker buildx build for testing.

Architecture map

AreaLocationResponsibility
CompositionSnacks/Program.csDI, Kestrel, SQLite, logging, middleware, routes.
HTTP APISnacks/Controllers/UI pages, JSON endpoints, cluster RPC.
PipelineSnacks/Services/TranscodingService.cs, VideoTransformPlanner.csQueue, FFmpeg execution, pure transform planning, retry, validation.
ClusterSnacks/Services/Cluster*.csDiscovery, orchestration, pure capacity policy, transfer, recovery.
PersistenceSnacks/Data/EF Core SQLite context, repositories, migrations.
Browser UISnacks/Views/, Snacks/wwwroot/Razor shell, ES modules, CSS, static assets.
Desktopelectron-app/Backend lifecycle, native window, packaging/signing.
TestsSnacks.Tests/, electron-app/tests/, e2e/Pipeline/cluster regressions, browser/API-contract checks, and multi-process scenarios.