Rindle
The CI badge tracks the ci.yml workflow run on main; that run's verdict is the
CI Summary gate — the sole required check for merge. GitHub has no native per-check
badge, so the workflow-run badge is the meaningful signal. Reproduce that gate locally
with mix ci (see CONTRIBUTING).
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.
Versioning and stability
Rindle follows Semantic Versioning. While Rindle is 0.x, public APIs may change between minor versions; review CHANGELOG.md and guides/upgrading.md before upgrading. Rindle 1.0 will mean the public API is stable enough that breaking public API changes move to major versions.
Install
Add Rindle to your deps:
def deps do
[
{:rindle, "~> 0.1"}
]
endIf you use the S3 adapter, also choose an ExAws HTTP client. Rindle's tested path uses Req so the S3 and GCS adapters share the same maintained transport:
def deps do
[
{:rindle, "~> 0.1"},
{:req, "~> 0.6"}
]
endConfigure ExAws to use it at build time:
config :ex_aws, http_client: ExAws.Request.ReqRun 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.
Runtime Ownership
Rindle persists through your adopter-owned Repo. Configure that explicitly:
config :rindle, :repo, MyApp.RepoRindle 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
Create separate normal host-app migrations for the two pieces of database
state. Oban.Migration creates the host-owned public.oban_jobs table; the
host's migration ledger remains public.schema_migrations. Rindle.Migration
creates only Rindle-owned tables and never creates or owns either host relation.
defmodule MyApp.Repo.Migrations.AddObanJobs do
use Ecto.Migration
def up, do: Oban.Migration.up()
def down, do: Oban.Migration.down(version: 1)
endThen install Rindle's tables with a pinned migration version. The default call
omits :prefix and creates Rindle state in the rindle schema. The only
compatibility pairing is an explicit prefix: "public" call in a separate host
migration with a public-compiled release for the public schema; it is not an
arbitrary-prefix mode.
defmodule MyApp.Repo.Migrations.InstallRindle do
use Ecto.Migration
def up, do: Rindle.Migration.up(version: 1)
def down, do: Rindle.Migration.down(version: 1)
endFor that explicit public compatibility pairing only, use the matching public-compiled release and a separate host migration:
defmodule MyApp.Repo.Migrations.InstallPublicRindle do
use Ecto.Migration
def up, do: Rindle.Migration.up(version: 1, prefix: "public")
def down, do: Rindle.Migration.down(version: 1, prefix: "public")
endRun your host app's normal migration workflow, then verify setup:
mix ecto.migrate
mix rindle.doctor
Rollback:
Rindle.Migration.down/1is destructive. Back up the database before runningRindle.Migration.down(version: 1); it removes Rindle-owned tables only and does not manage host infrastructure such asoban_jobsorschema_migrations.
Upgrade note: Existing apps that already applied Rindle's legacy packaged migrations can leave them in place. The new module is the documented install path going forward; it does not require replaying or deleting legacy migration files.
First Attachment in ~2 Minutes
Create first attachment with an original-only image profile before you add variants or AV processing. This keeps the first path focused on the upload, verify, attach, and URL lifecycle:
defmodule MyApp.AvatarProfile do
use Rindle.Profile,
storage: Rindle.Storage.S3,
variants: [],
allow_mime: ["image/png", "image/jpeg", "image/webp"],
max_bytes: 8_000_000
endThe first-run path is direct upload by presigned PUT:
{:ok, session} =
Rindle.initiate_upload(MyApp.AvatarProfile, filename: "avatar.png")
{: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, "avatar")
{:ok, signed_url} =
Rindle.url(MyApp.AvatarProfile, asset.storage_key)That proves the durable lifecycle with the original file first. Install
libvips before image variants or background image processing, and install
FFmpeg >= 6.0 before AV work. The per-platform runtime dependency details
live in Running.
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
endIf 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)
endRindle.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
endEnd-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).
Admin Console (optional)
Rindle ships a mountable, host-authenticated admin console in the package —
Rindle.Admin.Router.rindle_admin/2. Mount it from an authenticated router
scope (LiveDashboard / Oban Web style) for an operator view of assets, upload
sessions, variants/jobs, runtime health, and guarded owner-erasure / repair
actions. It needs the optional phoenix_live_view dependency, the host owns
auth, and production refuses unguarded mounts. See
Admin Console.
When Not to Use Rindle
Rindle is a Phoenix/Ecto library for media lifecycle work inside your application, not a hosted media platform. It does not run a daemon, act as a CDN replacement, provide DRM, become a full HLS/DASH streaming platform, ship an AI/GPU processing suite, or handle broad PDF/Office document processing. Those jobs belong to other tools; Rindle stays focused on durable upload, asset, variant, delivery, cleanup, and repair workflows in Phoenix apps.
Next Reads
- User Flows: map your job to the right guide (start here when evaluating)
- Admin Console: mount the optional host-authenticated operator console
- Upgrading: versioned existing-adopter upgrade guide and runbook home
- 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