Streaming Providers

Copy Markdown View Source

Rindle ships a single optional streaming provider for v1.6: Mux. This guide walks you through enabling signed HLS streaming end-to-end — dependencies, signing-key creation, profile configuration, webhook plug wiring, scheduled sync, local development, secret rotation, the mix rindle.doctor --streaming smoke check, an operator runbook for stuck assets, and a performance footgun note.

Adopters who only need progressive AV download stay on Rindle.Profile.Presets.Web — streaming is opt-in. The runtime cost of :mux and :jose is zero unless you opt a profile in.

This guide covers:

  • Why a streaming provider, and when not to opt in
  • Adding :mux and :jose as optional deps
  • Creating your Mux signing key out-of-band
  • Configuring a profile via Rindle.Profile.Presets.MuxWeb
  • Wiring the webhook plug end-to-end
  • Scheduling the sync coordinator cron worker
  • Local development with a webhook tunnel
  • Webhook secret rotation workflow
  • Running mix rindle.doctor --streaming smoke checks
  • Operator runbook for stuck provider assets
  • A performance note on high-throughput JWT signing

For the canonical AV-progressive-download path that does NOT require a streaming provider, see Secure Delivery.

1. Why a Streaming Provider?

Rindle's default delivery is progressive download via signed storage URL: adopters call Rindle.url/3, the storage adapter signs a time-limited URL, and the browser plays an MP4 directly off S3 / R2 / GCS. This works well for short clips, posters, and "click to play" flows.

When a profile opts into a streaming provider — by setting delivery: [streaming: %{provider: ...}] (or via Rindle.Profile.Presets.MuxWeb, which sets it for you) — the same Rindle.Delivery.streaming_url/3 call resolves the playback URL via the provider instead of via signed storage. For Mux, that means signed HLS playback URLs with adaptive bitrate, captions, and global delivery; the source media still lives in your storage adapter (Phase 33's "your bucket, our streaming" posture).

2. Add Mux to Your Dependencies

# mix.exs
defp deps do
  [
    # ... your existing deps ...
    {:rindle, "~> 0.1"},
    {:mux, "~> 3.2", optional: true},
    {:jose, "~> 1.11", optional: true}
  ]
end

Both deps are optional: true so adopters who never opt a profile into streaming pay zero transitive runtime cost.

Configure the runtime block (matches the Phase 34 D-29 layout exactly):

# config/runtime.exs
config :rindle, Rindle.Streaming.Provider.Mux,
  token_id: System.get_env("RINDLE_MUX_TOKEN_ID"),
  token_secret: System.get_env("RINDLE_MUX_TOKEN_SECRET"),
  signing_key_id: System.get_env("RINDLE_MUX_SIGNING_KEY_ID"),
  signing_private_key: System.get_env("RINDLE_MUX_SIGNING_PRIVATE_KEY"),
  webhook_secrets:
    System.get_env("RINDLE_MUX_WEBHOOK_SECRETS", "")
    |> String.split(",", trim: true)

Five environment variables; all required when MuxWeb is wired into a profile. Configuration is read at the call site, not cached, so runtime rotation works without a release restart.

3. Create Your Mux Signing Key

Signing keys are an out-of-band operation in Mux's dashboard. Rindle never auto-creates them — the adopter owns key custody.

  1. Sign in to your Mux dashboard.
  2. Navigate to Settings → Signing Keys → Create Signing Key.
  3. Mux returns the key id (public) and an RSA private key (PEM-encoded).
  4. Download the private key once — Mux does not let you re-download it. If you lose it, you must rotate.
  5. Store the private key in your secrets manager. Rindle reads it from RINDLE_MUX_SIGNING_PRIVATE_KEY at runtime.

The signing key id is non-sensitive and can live in non-secret config; the private key is secret-grade and MUST live in your secrets manager (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault, Fly secrets, etc.).

4. Configure Your Profile with MuxWeb

Rindle.Profile.Presets.MuxWeb is the streaming-on twin of Rindle.Profile.Presets.Web. It inherits the canonical web_720p + poster variants and locks the delivery.streaming block to Mux:

defmodule MyApp.Streaming do
  use Rindle.Profile.Presets.MuxWeb,
    storage: Rindle.Storage.S3,
    allow_mime: ["video/mp4", "video/quicktime", "video/webm"],
    max_bytes: 524_288_000
end

MuxWeb is a thin wrapper — same opts as Web (:storage, :allow_mime, :max_bytes), same variant set (web_720p + poster), plus a locked :delivery block:

delivery: [
  streaming: %{
    provider: Rindle.Streaming.Provider.Mux,
    playback_policy: :signed,
    ingest_mode: :server_push,
    source_variant: :web_720p
  }
]

There is no :scrub_strip opt-in for MuxWeb — streaming-enabled profiles are signed-playback by definition. Mux requires the source MP4 to be publicly fetchable for server-push ingest; Rindle generates a one-time signed source URL via Rindle.Delivery.streaming_url/3 source-variant resolution and hands it to Mux's create-asset call.

5. Wire the Webhook Plug

Mux notifies Rindle of asset readiness via signed webhook deliveries. The mountable Rindle.Delivery.WebhookPlug verifies HMAC signatures and enqueues an Oban worker for asynchronous processing.

Step 1 — install the body reader globally in endpoint.ex (BEFORE Plug.Parsers):

plug Plug.Parsers,
  parsers: [:urlencoded, :multipart, :json],
  pass: ["*/*"],
  body_reader: {Rindle.Delivery.WebhookBodyReader, :read_body, []},
  json_decoder: Jason

Step 2 — mount the Plug in router.ex, one forward per provider:

forward "/webhooks/rindle/mux", Rindle.Delivery.WebhookPlug,
  provider: Rindle.Streaming.Provider.Mux,
  secrets: {:application, :rindle, [Rindle.Streaming.Provider.Mux, :webhook_secrets]}

Step 3 — set RINDLE_MUX_WEBHOOK_SECRETS (comma-separated) in your runtime config, and configure your Mux dashboard webhook to POST to https://yourapp.example.com/webhooks/rindle/mux.

The Plug returns:

StatusBodyWhen
202 AcceptedemptyVerified + enqueued.
200 OKemptyVerified but dropped (event not in adapter dispatch table).
400 Bad Requestprovider_webhook_invalidSignature mismatch, replay-window failure, missing secrets, callback raised.
405 Method Not Allowedmethod not allowedNon-POST request.
500 Internal Server Errorserver_misconfiguredBody reader assign missing AND fallback empty.
503 Service UnavailableemptyOban enqueue raised (transient downstream failure — Mux retries).

6. Schedule the Sync Coordinator

Webhooks can be lost or delayed; Rindle ships a per-row reconciliation coordinator as a backstop. Schedule the coordinator from your Oban cron config; you do not need Rindle to supervise Oban.

config :my_app, Oban,
  queues: [rindle_provider: 4],
  plugins: [
    {Oban.Plugins.Cron,
     crontab: [
       {"* * * * *", Rindle.Workers.MuxSyncCoordinator}
     ]}
  ]

Cron resolution is 1 minute. The coordinator's internal query enforces a provider_polling_floor_seconds: 30 floor so rows that were just touched by a webhook are not redundantly polled.

The coordinator fans out per-row sync jobs only for media_provider_assets rows in (processing, uploading) state older than the floor. Per-row unique constraint dedupes within the 60s window so back-to-back cron ticks don't double-fan-out the same row.

7. Local Development with a Webhook Tunnel

To exercise the full webhook path locally, expose localhost:4000 to the public internet.

cloudflared tunnel --url http://localhost:4000

Cloudflare's TryCloudflare quick tunnel is signup-free and adequate for Mux webhook volume. See TryCloudflare docs. ngrok is a popular alternative; note that as of 2026 it requires account signup before a tunnel will start (see ngrok pricing). Update your Mux dashboard webhook URL to the tunnel-issued hostname while testing.

8. Webhook Secret Rotation Workflow

RINDLE_MUX_WEBHOOK_SECRETS is comma-separated for exactly this reason — multiple secrets verify in parallel during rotation.

  1. Add the new secret to the front of the comma-separated list: RINDLE_MUX_WEBHOOK_SECRETS=whsec_NEW,whsec_OLD.
  2. Rotate the corresponding secret in your Mux dashboard.
  3. Watch telemetry. Every verified webhook emits the [:rindle, :provider, :webhook, :verified] event with metadata %{provider, event_type, event_id, kind}; the provider-internal [:rindle, :provider, :mux, :webhook_attempt, :secret_used] event carries secret_index (0-based offset into the secrets list). Subscribe to confirm new secrets are in active use before retiring the old one.
  4. Wait 24 hours as a grace window for in-flight retries from Mux.
  5. Retire the old secret by removing it from the list: RINDLE_MUX_WEBHOOK_SECRETS=whsec_NEW.

The grace window is a recommendation, not a contract — if you have high webhook volume and observable telemetry, you can shorten it to match your actual retry tail (Mux retries up to 24h with exponential backoff for 5xx responses).

9. Run mix rindle.doctor --streaming

The doctor task includes four streaming-aware checks. Without --streaming, the smoke-ping check skips (offline-friendly default). With --streaming, the doctor performs a 5-second smoke ping against api.mux.com.

mix rindle.doctor --streaming

Expected PASS output:

[ok] doctor.streaming_credentials: All five RINDLE_MUX_* credentials are set.
[ok] doctor.streaming_signing_key: RINDLE_MUX_SIGNING_PRIVATE_KEY parses as a valid JOSE JWK.
[ok] doctor.streaming_webhook_secrets: RINDLE_MUX_WEBHOOK_SECRETS has 1 secret(s), all  32 chars.
[ok] doctor.streaming_smoke_ping: Mux.Video.Assets.list/1 returned 200 (smoke ping OK).

Failure-mode taxonomy for doctor.streaming_smoke_ping:

ResultFix
HTTP 200OK — no action needed.
HTTP 401 / 403Verify RINDLE_MUX_TOKEN_ID and RINDLE_MUX_TOKEN_SECRET in your runtime config.
HTTP 429Mux rate-limited the smoke ping; retry in a few seconds.
Timeout / connection errorCould not reach api.mux.com within 5s; check network / proxy / DNS.
Other 4xx / 5xxFix references the response status; consult Mux status page.

If no profile in the application opts into streaming, all four checks return ok with summary "No streaming-enabled profiles discovered." — mirrors the vacuous-OK posture of doctor.local_playback.

10. Operator Runbook: Stuck Assets

Mux occasionally drops a webhook; the sync coordinator reconciles. When neither the webhook nor the cron has cleared a row within the configured threshold (provider_stuck_threshold_seconds: 7200 default, 2 hours), the row is considered stuck.

Inspect stuck assets:

mix rindle.runtime_status --provider-stuck

The report enumerates media_provider_assets rows in (processing, uploading) state older than the threshold, with provider_asset_id redacted to last-4 chars per security invariant 14. From there you can manually re-fetch from Mux or cancel.

To cancel stuck IngestProviderWebhook jobs in Oban:

# In an IEx console:
Oban.cancel_jobs(Rindle.Workers.IngestProviderWebhook)

A higher-level Rindle.cancel_provider_ingest/1 API is planned for v0.3+; until then, use Oban's job-cancellation surface directly.

11. Performance Note: High-Throughput JWT Signing

For adopters above ~1,000 playback URLs/sec, JOSE.JWK.from_pem/1 becomes a hot path because Rindle re-parses the PEM on every signed-URL call. The recommended optimization is a :persistent_term cache keyed by signing key id; an in-library cache ships in v0.3+. Until then, you can patch the cache yourself by wrapping Rindle.Streaming.Provider.Mux.sign_playback_id/2 in your application.

For most adopters (<100 playback URLs/sec) this is below the noise floor and no action is needed.

Quick Reference

Telemetry events you can subscribe to from your application:

EventPayloadWhen

| [:rindle, :provider, :webhook, :verified] | %{provider, event_type, event_id, kind} (kind: :enqueued | :dropped) | Successful HMAC verification. | | [:rindle, :provider, :webhook, :rejected] | %{provider, reason} (reason: :sig_mismatch | :no_secrets_configured | :body_reader_missing | :provider_callback_raised | :method_not_allowed | :oban_unavailable) | Verification or pre-verify check failed. | | [:rindle, :provider, :mux, :webhook_attempt, :secret_used] | %{secret_index} | HMAC verification succeeded for the given secret offset. | | [:rindle, :provider, :mux, :webhook_attempt, :rejected] | %{secret_index, sdk_reason} | HMAC verification failed for the given secret offset. |

Configuration reference:

GoalConfiguration
Enable Mux streaming on a profileuse Rindle.Profile.Presets.MuxWeb, storage: ..., allow_mime: [...], max_bytes: ...
Multi-secret rotationRINDLE_MUX_WEBHOOK_SECRETS=whsec_NEW,whsec_OLD
Adjust polling floorconfig :rindle, Rindle.Streaming.Provider.Mux, provider_polling_floor_seconds: 30
Adjust stuck thresholdconfig :rindle, Rindle.Streaming.Provider.Mux, provider_stuck_threshold_seconds: 7200
Local webhook tunnelcloudflared tunnel --url http://localhost:4000
Smoke check before deploymix rindle.doctor --streaming