rindle

Rindle

CI Hex.pm Docs

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"}
  ]
end

If 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"}
  ]
end

Configure ExAws to use it at build time:

config :ex_aws, http_client: ExAws.Request.Req

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.

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

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)
end

Then 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)
end

For 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")
end

Run your host app's normal migration workflow, then verify setup:

mix ecto.migrate
mix rindle.doctor

Rollback: Rindle.Migration.down/1 is destructive. Back up the database before running Rindle.Migration.down(version: 1); it removes Rindle-owned tables only and does not manage host infrastructure such as oban_jobs or schema_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
end

The 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:

  1. mix deps.get
  2. install FFmpeg >= 6.0 from Running
  3. declare one kind: :video variant plus the stock poster
  4. run mix rindle.doctor
  5. 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).

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

Documentation conventions

Every public @callback must be preceded by @doc """...""". Use @doc false only for internal compatibility shims.

License

MIT