Rindle
Media, made durable.
Phoenix/Ecto-native media lifecycle library. Rindle owns the durable work that happens after upload: session tracking, verification, asset state, variants, background processing, signed delivery, and cleanup.
The first-tier adopter concepts are Rindle and Rindle.Profile: define a
profile once, then use the facade for upload lifecycle, attachments, and
delivery.
This file is the narrow quickstart. Getting Started is the canonical deep adopter guide for the same first-run path. That path is validated in CI from generated Phoenix apps (image-only and AV-enabled install smoke) before each Hex publish. Existing adopters upgrading from the pre-0.1.4 image-only shape should use Upgrading instead of stretching the greenfield quickstart into an upgrade runbook.
Install
Add Rindle to your deps:
def deps do
[
{:rindle, "~> 0.1"}
]
end
If you use the S3 adapter, also choose an ExAws HTTP client. :hackney is the
most-tested path in this repo:
def deps do
[
{:rindle, "~> 0.1"},
{:hackney, "~> 1.20"}
]
end
Run mix deps.get.
Each release is exercised from a generated Phoenix app in CI before it ships to Hex. Adopters follow the same public setup contract described here and in Getting Started.
For AV profiles, install FFmpeg >= 6.0 before you touch background jobs, then
run mix rindle.doctor. The per-platform install/runtime matrix lives in
Running.
For image variants, install libvips on the host before background image
processing jobs run (libvips-dev on Debian/Ubuntu, vips via Homebrew on
macOS). See Running for the install matrix.
Runtime Ownership
Rindle persists through your adopter-owned Repo. Configure that explicitly:
config :rindle, :repo, MyApp.Repo
Rindle also requires the default Oban path for background work. Adopters own
the Oban supervision tree, queue config, and default Oban Repo:
config :my_app, Oban,
repo: MyApp.Repo,
queues: [
rindle_promote: 5,
rindle_process: 10,
rindle_purge: 2,
rindle_maintenance: 1
]
Migrations
Run your host app migrations and the packaged Rindle migrations explicitly:
rindle_path = Application.app_dir(:rindle, "priv/repo/migrations")
host_path = Path.join([File.cwd!(), "priv", "repo", "migrations"])
{:ok, _, _} =
Ecto.Migrator.with_repo(MyApp.Repo, fn repo ->
for path <- [host_path, rindle_path] do
Ecto.Migrator.run(repo, path, :up, all: true)
end
end)
Rindle does not ship a public mix rindle.* install task for migrations. The
public path is the docs snippet above.
First Run: AV Quickstart
The locked onboarding path is:
mix deps.get- install
FFmpeg >= 6.0from Running - declare one
kind: :videovariant plus the stock poster - run
mix rindle.doctor - follow the normal facade-first upload lifecycle
The canonical deep guide expands the same path in
Getting Started. The stock onboarding story is
Rindle.Profile.Presets.Web: web_720p video output plus poster image
output. The equivalent explicit profile looks like this:
defmodule MyApp.VideoProfile do
use Rindle.Profile,
storage: Rindle.Storage.S3,
variants: [
web_720p: [kind: :video, preset: :web_720p],
poster: [kind: :image, preset: :video_poster_scene]
],
allow_mime: ["video/mp4", "video/quicktime", "video/webm"],
max_bytes: 250_000_000
end
If you prefer the stock preset module directly, use
Rindle.Profile.Presets.Web with the same storage and upload constraints, then
verify the host with:
mix rindle.doctor
Once the runtime is healthy, the first-run path is still direct upload by presigned PUT. Multipart upload is supported, but it is an advanced capability and not the default onboarding story.
{:ok, session} =
Rindle.initiate_upload(MyApp.VideoProfile, filename: "clip.mp4")
{:ok, %{session: signed, presigned: presigned}} =
Rindle.Upload.Broker.sign_url(session.id)
# your client PUTs bytes to presigned.url
{:ok, %{session: completed, asset: asset}} =
Rindle.verify_completion(session.id)
{:ok, attachment} =
Rindle.attach(asset.id, current_user, "hero_video")
{:ok, signed_url} =
Rindle.url(MyApp.VideoProfile, asset.storage_key)
That keeps the first-run story on the facade while leaving
Rindle.Upload.Broker.sign_url/1 as an advanced transport step.
After First Run: Querying Attachments and Variants
Once an asset is attached, you'll typically render it from a Phoenix controller or LiveView. Two helpers cover the common reads without writing raw Ecto queries:
# In MyAppWeb.UserController.show/2
def show(conn, _params) do
user = conn.assigns.current_user
{avatar, thumbs} =
case Rindle.attachment_for(user, "avatar") do
%{asset: asset} = attachment ->
{attachment, Rindle.ready_variants_for(asset)}
nil ->
{nil, []}
end
# avatar is %Rindle.Domain.MediaAttachment{} | nil
# thumbs is [] when no attachment exists
render(conn, :show, avatar: avatar, thumbs: thumbs)
end
Rindle.attachment_for/2 returns the most recent attachment for an
(owner, slot) pair (tie-broken by inserted_at desc) with :asset
preloaded. Pass Rindle.attachment_for(user, "avatar", preload: [:asset, :variants])
to override the preload list (REPLACE semantics, not merge).
Rindle.ready_variants_for/1 accepts either a %MediaAsset{} struct or a
binary asset id and returns variants with state == "ready", ordered by
:name asc. Pending or failed variants are filtered out.
Bang Variants
Five bang variants are available for happy-path code that prefers
exceptions over {:error, reason} tuples. Each delegates to its non-bang
twin and raises Rindle.Error on generic failures:
# Raises Rindle.Error{action: :attach, reason: :not_found} if the asset is missing.
attachment = Rindle.attach!(asset.id, current_user, "avatar")
# Raises Rindle.Error{action: :detach, reason: ...} on storage failure.
:ok = Rindle.detach!(current_user, "avatar")
# Raises Rindle.Error{action: :upload, reason: ...} on validation/storage failure.
asset = Rindle.upload!(MyApp.MediaProfile, %{
path: "/tmp/photo.png",
filename: "photo.png",
byte_size: File.stat!("/tmp/photo.png").size
})
# Raises Rindle.Error{action: :url, reason: :delivery_unsupported} if the
# configured storage adapter does not advertise :signed_url capability.
signed = Rindle.url!(MyApp.MediaProfile, asset.storage_key)
# Raises Rindle.Error{action: :variant_url, reason: :variant_not_ready} if
# the named variant has not finished processing.
thumb_url = Rindle.variant_url!(MyApp.MediaProfile, asset, :thumb)
Bangs are intended for happy-path callers (controllers, scripts, tests).
For user-facing flows that must render validation errors, prefer the
non-bang twins (Rindle.attach/4, Rindle.detach/3, Rindle.upload/3,
Rindle.url/3, Rindle.variant_url/4) which return {:ok, value} /
{:error, reason} tuples.
Streaming with Mux (optional)
For HLS streaming via signed playback URLs, opt a profile into a streaming provider:
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
End-to-end onboarding — signing keys, webhook plug, cron, local tunnel, secret rotation, and mix rindle.doctor --streaming — lives in Streaming Providers.
Storage with GCS (optional)
GCS resumable upload is a shipped advanced path, not the canonical first-run
story. If you need Rindle.Storage.GCS, adopter-owned MyApp.Goth and
MyApp.Finch supervision, bucket CORS, and resumable session hygiene, validate
the runtime with mix rindle.doctor and use
Storage (GCS).
Next Reads
- User Flows: map your job to the right guide (start here when evaluating)
- Upgrading: existing-adopter upgrade runbook (pre-0.1.4 image-only → current)
- Getting Started: deep greenfield guide — Repo, Oban, migrations, profiles
- Running: libvips and FFmpeg install matrix (macOS, Linux, Fly, Heroku, Render, CI)
- Background Processing: Oban queues and worker behavior
- Storage Capabilities: adapter capability boundaries
Documentation conventions
Every public @callback must be preceded by @doc """...""". Use @doc false only for internal compatibility shims.
License
MIT