This is the map. The other guides go deep on one area each; this one steps back and shows the whole territory — what you can actually get done with Rindle, organized by the job you're trying to do rather than by the module that does it.
If you've just arrived, read Getting Started first to wire up the basics, then come back here to see the breadth. If you're evaluating Rindle, start here.
The mental model in one paragraph
Rindle owns everything that happens after the upload button. You keep your controllers, your LiveViews, your schemas, your auth. Rindle takes over the durable, easy-to-get-wrong middle: handing out direct-to-storage upload tickets, verifying the bytes really landed, modeling each asset and its derivatives as queryable database rows, generating variants in the background, serving private signed URLs, and cleaning up after itself. It is a library, not a platform — it doesn't run a daemon, replace your CDN, or become a streaming service. It makes media a normal, observable part of your Phoenix app.
The cast
Four kinds of people show up to Rindle. You're probably the first one.
| Who | What they come for |
|---|---|
| App developer (you, most days) | Attach media to a schema, take an upload safely, render a responsive or private URL. |
| Platform / senior engineer | Set media policy per use case, swap storage backends, extend the processing pipeline. |
| Operator / SRE | See what's stuck, repair stale or missing media, keep storage spend bounded. |
| Security / compliance | Trust that untrusted files are handled safely and delivery is restricted by default. |
Find your job
Scan for the row that sounds like your sentence, then jump to the story or guide.
| When you want to… | You reach for… | Go deeper |
|---|---|---|
| Upload straight to storage, bytes never touching your server | Rindle.initiate_upload/2 → Rindle.Upload.Broker.sign_url/2 → Rindle.verify_completion/2 | Avatar in five calls |
| Take an upload server-side, simply | Rindle.upload/3 | Getting Started |
| Upload a multi-GB file in parts | Rindle.initiate_multipart_upload/2 + sign_multipart_part/3 + complete_multipart_upload/3 | The 4 GB upload from a moving train |
| Let a phone resume after the signal drops | Rindle.initiate_resumable_session/2 + resumable_session_status/2 | Storage (GCS) |
| Attach an asset to one of your schemas | Rindle.attach/4 | Avatar in five calls |
| Replace media and auto-clean the old file | Rindle.attach/4 (idempotent replace) | Replace, detach, forget |
| Detach + purge when a record is deleted | Rindle.detach/3 | Replace, detach, forget |
| Read the current attachment in a template | Rindle.attachment_for/3 | Replace, detach, forget |
| Make thumbnails / resized variants | Rindle.Profile variants: + Rindle.Processor.Image | Profiles |
Render a responsive <picture> | Rindle.HTML.picture_tag/3 | Avatar in five calls |
| Transcode video/audio + poster | Rindle.Profile.Presets.Web, Rindle.Processor.AV | The creator uploads a lesson |
| Stream video via Mux with signed playback | Rindle.Profile.Presets.MuxWeb, Rindle.Delivery.streaming_url/3 | Streaming Providers |
| Serve private, expiring URLs by default | Rindle.url/3, variant_url/4 | Secure Delivery |
| Gate delivery on your own auth | Rindle.Authorizer behaviour | Secure Delivery |
| Wire uploads into LiveView with live progress | Rindle.LiveView.allow_upload/4 + consume_uploaded_entries/3 + subscribe/2 | LiveView, reactively |
| Run on S3 / R2 / MinIO, GCS, or local disk | Rindle.Storage.{S3,GCS,Local} | Storage Capabilities |
| Plug in your own analyzer / processor / scanner | Rindle.Analyzer, Rindle.Processor, Rindle.Scanner | Profiles |
| Fix bad metadata, retry failed variants, cancel work | Rindle.reprobe/1, requeue_variants/2, cancel_processing/1 | Friday, 5 p.m., something is stuck |
| See what's stuck and repair it | Rindle.runtime_status/1, mix rindle.doctor, the mix rindle.* ops tasks | Operations |
The flows, told as stories
These six cover the surface most apps touch. Each uses one running example — Cohort, a hypothetical course-and-community SaaS that needs avatars, post images, and lesson videos. Swap the names for yours; the shape is the point.
Story 1: Avatar in five calls
A member uploads a profile photo. You never want the image bytes flowing through your app server, so you hand the browser a presigned ticket and let it talk to S3 directly.
First, declare the policy once. A profile is the single source of truth for one kind of media:
defmodule Cohort.AvatarProfile do
use Rindle.Profile,
storage: Rindle.Storage.S3,
allow_mime: ["image/png", "image/jpeg", "image/webp"],
max_bytes: 8_000_000,
delivery: [public: false, signed_url_ttl_seconds: 900],
variants: [
thumb: [mode: :fit, width: 128, height: 128],
large: [mode: :fit, width: 1024, height: 1024]
]
endThen the five-call dance — three on the way up, two to verify and link:
# 1. Mint a staged asset + upload session
{:ok, session} = Rindle.initiate_upload(Cohort.AvatarProfile, filename: "me.jpg")
# 2. Get a presigned PUT the browser can use directly
{:ok, %{presigned: put}} = Rindle.Upload.Broker.sign_url(session.id)
# -> hand put.url / put.headers to the client; it PUTs the bytes to S3
# 3. After the client's PUT succeeds, confirm the object really landed
{:ok, %{asset: asset}} = Rindle.verify_completion(session.id)
# -> asset is now "validating"; Rindle promotes it and builds variants in the background
# 4. Link it to the member at a named slot ("avatar")
{:ok, _attachment} = Rindle.attach(asset.id, current_user, "avatar")Rendering is the fifth call — and it doesn't care whether the variants finished yet, because
picture_tag/3 falls back to the original automatically:
<%= Rindle.HTML.picture_tag(Cohort.AvatarProfile, @asset,
variants: [{:thumb, "(max-width: 480px)"}, {:large, nil}],
alt: "Member avatar"
) %>That's the spine of almost every flow: initiate → sign → verify → attach → render.
Story 2: The creator uploads a lesson
Now a course creator uploads a 12-minute lesson video. You want a web-friendly 720p
rendition and a poster frame, and you'd rather not hand-write FFmpeg flags. The Web preset
declares both outputs for you:
defmodule Cohort.LessonVideo do
use Rindle.Profile.Presets.Web,
storage: Rindle.Storage.S3,
allow_mime: ["video/mp4", "video/quicktime"],
max_bytes: 2_000_000_000
# gives you a :web_720p video variant and a :poster image variant
endThe upload path is identical to Story 1 (initiate → sign → verify). Once the bytes land,
Rindle probes the file with FFprobe, transcodes :web_720p and extracts :poster on
background workers, and flips each variant to "ready" when it's done. You render:
<%= Rindle.HTML.video_tag(Cohort.LessonVideo, @asset,
variants: [:web_720p],
poster: :poster,
controls: true
) %>When Cohort outgrows progressive MP4 and wants adaptive streaming, the jump is small: switch
the profile to Rindle.Profile.Presets.MuxWeb, and ask for a playback URL through the same
delivery surface instead of a static one:
{:ok, playback_url} = Rindle.Delivery.streaming_url(Cohort.LessonVideo, asset)Rindle pushes the asset to Mux in the background, listens for the readiness webhook, and mints a signed playback URL — no template churn. The full setup lives in Streaming Providers.
Story 3: The 4 GB upload from a moving train
A creator records a long workshop and uploads it from a laptop on spotty WiFi. A single PUT would fail halfway and start over. Two flows fix this, and your storage backend decides which one is available — Rindle won't offer a capability the adapter can't honor (see Storage Capabilities).
Multipart (S3-family): split into parts, sign each, complete:
{:ok, %{session: session}} = Rindle.initiate_multipart_upload(Cohort.LessonVideo)
# for each part the client wants to send:
{:ok, part_put} = Rindle.sign_multipart_part(session.id, part_number)
# once all parts are uploaded, hand back the part etags:
{:ok, %{asset: asset}} = Rindle.complete_multipart_upload(session.id, parts)Resumable (GCS): open a session the client can keep PATCHing into, and poll its progress:
{:ok, %{session: session}} = Rindle.initiate_resumable_session(Cohort.LessonVideo)
{:ok, status} = Rindle.resumable_session_status(session.id) # committed bytes, stateBoth funnel back into the same verify_completion/2 → promotion → processing pipeline as
every other upload. The session URI in a resumable flow is a bearer credential, so Rindle
keeps it out of logs, telemetry, and inspect/2 — you don't have to remember to redact it.
Story 4: LiveView, reactively
Cohort's settings page is a LiveView, and you want the avatar to upload and show progress without a full round trip. Rindle wraps Phoenix's external-upload mechanism:
def mount(_params, _session, socket) do
{:ok, Rindle.LiveView.allow_upload(socket, :avatar, Cohort.AvatarProfile)}
end
def handle_event("save", _params, socket) do
Rindle.LiveView.consume_uploaded_entries(socket, :avatar, fn _entry, meta ->
{:ok, attachment} = Rindle.attach(meta.asset_id, socket.assigns.current_user, "avatar")
{:ok, attachment}
end)
{:noreply, socket}
endWant a live "processing… ready" badge? Subscribe to the asset and handle the broadcasts:
Rindle.LiveView.subscribe(:asset, asset_id)
def handle_info({:rindle_event, type, payload}, socket) do
# type is :variant_ready, :state_change, ...
{:noreply, assign(socket, :media_state, payload.state)}
endStory 5: Replace, detach, forget
A member swaps their avatar. You don't orphan the old file — attach/4 to an occupied slot
replaces the link and schedules the previous asset's storage purge after the database
commit, so you never leave a half-deleted object behind:
{:ok, _} = Rindle.attach(new_asset.id, current_user, "avatar") # old one purged asyncA member deletes a post that had a hero image. Detach is idempotent — safe to call whether or not anything was attached:
:ok = Rindle.detach(post, "hero")And in any render path, read the current attachment without side effects (the asset is preloaded by default):
case Rindle.attachment_for(current_user, "avatar") do
nil -> render_default_avatar()
attachment -> render_avatar(attachment.asset)
endNeed to delete an account without guessing which media survives? Use the owner/account erasure facade instead of teaching a detach loop:
{:ok, preview} = Rindle.preview_owner_erasure(current_user)
# preview.attachments_to_detach -> Rindle-managed associations removed now
# preview.assets_to_purge -> newly orphaned assets queued for purge later
# preview.retained_shared_assets -> shared assets kept because another attachment survives
{:ok, report} = Rindle.erase_owner(current_user)Rindle.preview_owner_erasure/2 is the dry run and Rindle.erase_owner/2 is
the execute lane. The report keeps three semantic buckets stable:
attachments_to_detach, assets_to_purge, and retained_shared_assets.
Rindle only erases Rindle-managed associations for that owner. It does not delete your adopter-owned account row. Execute semantics stay honest: detach now, purge later. Newly orphaned assets are enqueued for async cleanup, while retained shared assets stay in storage whenever another live attachment survives.
Batch owner erasure
Need to erase several owners in one orchestrated pass? Use the batch facade:
owners = [user_a, user_b]
{:ok, preview} = Rindle.preview_batch_owner_erasure(owners)
{:ok, report} = Rindle.erase_batch_owner_erasure(owners)Operators can drive the same contract from the shell with
mix rindle.batch_owner_erasure --owners-file PATH. Run
mix help rindle.batch_owner_erasure for the full CLI contract.
Batch semantics to keep in mind:
- Each owner runs in its own transaction, sequentially — earlier owners may commit before a later owner fails.
- Duplicate owner refs in the input list are deduped; an optional
max_ownerslimit rejects over-large batches before any work starts. - On per-owner failure,
Rindle.erase_batch_owner_erasure/2returns{:error, {:batch_owner_failed, detail}}withdetail.partial_reportlisting only owners that completed successfully. - After fixing the failing owner, rerun the batch idempotently for the remaining owners.
- Shared assets use the same
retained_shared_assetsbucket as single-owner erasure whenever another live attachment survives.
mix rindle.cleanup_orphans remains maintenance-only upload-residue cleanup,
not the supported account-deletion API. Batch multi-owner erasure is supported
via Rindle.preview_batch_owner_erasure/2, Rindle.erase_batch_owner_erasure/2,
and mix rindle.batch_owner_erasure. Admin UI, force-delete policy for
still-shared assets, and scheduler/cron erasure jobs remain deferred.
Story 6: Friday, 5 p.m., something is stuck
An operator notices a few lesson videos that never went "ready." Rindle is built so this is a two-minute investigation, not an archaeology dig — because every asset, variant, and session is a queryable row with explicit state, never hidden in a filename.
mix rindle.doctor # is FFmpeg present? storage reachable? streaming wired?
mix rindle.runtime_status # bounded report: stuck work, upload residue, lifecycle drift
From there the repair surfaces are explicit and asset-scoped — no Oban spelunking required:
Rindle.reprobe(asset_id) # re-detect mime/dimensions/duration
Rindle.requeue_variants(asset_id, variant_names: ["web_720p"]) # retry only the failures
Rindle.cancel_processing(asset_id) # stop in-flight workAnd the scheduled-task family keeps storage honest over time:
mix rindle.regenerate_variants, mix rindle.verify_storage (DB vs. storage reconciliation),
mix rindle.cleanup_orphans, mix rindle.abort_incomplete_uploads. See
Operations.
What you inherit for free
You don't have to ask for these — they're the defaults, and they're the reason Rindle exists instead of a folder of glue code:
- No bytes through your app. Presigned/direct upload is the primary path.
- Files are validated by content, not by their claimed name. Magic-byte sniffing plus
allowlists; the
Rindle.Scannerhook can quarantine before anything goes live. - Private by default. Delivery is signed and expiring unless you opt a profile into public (CDN-cacheable) URLs — see Secure Delivery.
- Cleanup never corrupts state. Storage side effects happen after the DB commit, and purges are async, idempotent, and auditable.
- Nothing is a black box. Assets, variants, and sessions are first-class rows with explicit state machines you can query, filter, and repair — the model is in Core Concepts.
- Your dashboards won't break silently. Telemetry event names and metadata are a public contract (see Background Processing).
Where Rindle is headed
Rindle already covers the full core lifecycle for images, video, and audio across S3-family, GCS, and local storage — including tus resumable uploads (Local and S3) and browser → Mux direct creator upload (shipped v1.8–v1.11). Remaining work is demand-gated: compliance force-delete for shared assets, a second streaming provider when a named adopter appears, and long-tail polish (signed dynamic transforms, EXIF stripping on originals).
Deliberately out of scope, by design: being a full HLS/DASH streaming platform, DRM, AI/GPU processing, broad PDF/Office handling, an admin UI, or a CDN replacement. Rindle stays a focused library; those belong to other tools.