Getting Started with Rindle

Copy Markdown View Source

Rindle is a Phoenix/Ecto-native media lifecycle library. It manages the work that happens after upload: durable upload sessions, verification, asset state, variants, signed delivery, and day-2 cleanup.

The first-tier adopter concepts are Rindle and Rindle.Profile: define a profile once, then stay on the facade for the common upload, attach, and delivery path.

This is the canonical deep adopter guide for the same first-run path shown in README.md. The lifecycle calls below are the same public path the repo proves in test/adopter/canonical_app/lifecycle_test.exs and the Phase 29 package-consumer proof matrix. That matrix exercises a generated Phoenix app from installed artifacts in two lanes: image-only and AV-enabled. The built-artifact proof runs before publish, and the published-artifact proof reuses the same generated-app posture against the released Hex package. If you are upgrading an existing adopter from the pre-v1.4 image-only shape, stop here and use the dedicated runbook in upgrading.md.

1. Add Dependencies

Add Rindle to your deps:

def deps do
  [
    {:rindle, "~> 0.1"}
  ]
end

If you use Rindle.Storage.S3, 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.

Phase 29 keeps two outside-in package-consumer truths aligned:

  1. image-only adopters can install Rindle into a generated app and complete upload, processing, and signed delivery from the installed artifact
  2. AV-enabled adopters can install Rindle into a generated app and prove probe, transcode, poster generation, local playback-ready output, and signed delivery from the installed artifact

Those are maintainer proof lanes, but they intentionally exercise the same public docs snippets and facade calls adopters use in a real host app.

The first AV onboarding path is explicit:

  1. mix deps.get
  2. install FFmpeg >= 6.0 for the target platform
  3. define one kind: :video variant plus poster
  4. run mix rindle.doctor
  5. then let the stock facade lifecycle process the upload

Use ../RUNNING.md for the per-platform FFmpeg install surface: macOS/Homebrew, Ubuntu or Debian/apt, Alpine/apk, Fly.io Dockerfile, Heroku Aptfile, Render Dockerfile, and GitHub Actions via FedericoCarboni/setup-ffmpeg.

2. Configure Adopter-Owned Runtime Boundaries

Rindle persists runtime state through your adopter-owned Repo. Configure that explicitly:

config :rindle, :repo, MyApp.Repo

That is the adopter contract for public runtime paths such as Rindle.initiate_upload/2, Rindle.verify_completion/2, Rindle.attach/4, Rindle.detach/3, Rindle.upload/3, and Rindle.url/3.

Rindle also requires the default Oban path for background work. Adopters own Oban supervision, queue config, and the default Oban Repo:

config :my_app, Oban,
  repo: MyApp.Repo,
  queues: [
    rindle_promote: 5,
    rindle_process: 10,
    rindle_purge: 2,
    rindle_maintenance: 1
  ]

Then start Oban in your application:

children = [
  MyApp.Repo,
  {Oban, Application.fetch_env!(:my_app, Oban)}
]

Named-instance or custom :oban_name routing is not the v1.1 contract. The shipped path is the default Oban module.

3. Run Host-App And Rindle Migrations Explicitly

Your app owns its own migrations, and Rindle ships a second migration path inside the package. The package-consumer generated-app proof proves the explicit Application.app_dir(:rindle, "priv/repo/migrations") handoff below:

Application.ensure_all_started(:rindle)
{:ok, _pid} = MyApp.Repo.start_link()

host_path = Path.join([File.cwd!(), "priv", "repo", "migrations"])
rindle_path = Application.app_dir(:rindle, "priv/repo/migrations")

unless File.dir?(rindle_path) do
  raise "Rindle migration path missing: #{rindle_path}"
end

{: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 add a public mix rindle.* install task in Phase 29. The public install path is this docs snippet; the repo-private helper that automates it exists only inside the package-consumer smoke harness.

If your app uses binary IDs globally, keep your Repo migration defaults aligned with your host app conventions before running the shared path.

4. Define The Canonical AV Profile

Phase 29 locks the public AV story to the stock web_720p plus poster surface. Rindle.Profile.Presets.Web is the canonical helper, and its explicit equivalent 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 want the stock preset module directly, this is the same public shape:

defmodule MyApp.VideoProfile do
  use Rindle.Profile.Presets.Web,
    storage: Rindle.Storage.S3,
    allow_mime: ["video/mp4", "video/quicktime", "video/webm"],
    max_bytes: 250_000_000
end

The Profile DSL validates options at compile time so invalid configuration fails before runtime upload flows begin. Before you enqueue any AV work, verify the runtime:

mix rindle.doctor

mix rindle.doctor should be the last setup step before you touch background jobs or chase variant failures.

5. First-Run Upload Lifecycle

The first-run path is presigned PUT. It is the narrowest direct-upload contract Rindle proves from the built artifact, the published artifact, and the source-of-truth adopter test:

{: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 the file 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)

The parity gate for this guide and README.md asserts the canonical lifecycle calls above: Rindle.initiate_upload, Rindle.verify_completion, Rindle.attach, and Rindle.url.

Rindle.Upload.Broker.sign_url/1 stays available for the presign transport step, but it is reference material rather than the first concept adopters should learn.

Multipart upload is available, but it belongs in the advanced lane after the presigned PUT path is working. See storage_capabilities.md for the capability contract and proof boundaries.

If you prefer a proxied/server-side upload, the same adopter-owned Repo contract applies:

{:ok, asset} =
  Rindle.upload(MyApp.VideoProfile, %{
    path: "/tmp/clip.mp4",
    filename: "clip.mp4",
    byte_size: File.stat!("/tmp/clip.mp4").size
  })

The proof matrix keeps this guide disciplined:

  • built-artifact proof validates the generated app before publish
  • published-artifact proof re-runs the same generated-app posture against a released Hex version
  • existing-adopter upgrade procedure lives in upgrading.md
  • maintainer-only release orchestration stays in release_publish.md, not here

6. What Happens After Verification

After Rindle.verify_completion/1 returns, Rindle enqueues background work in Oban:

  1. An internal promote worker advances the asset through validation, analysis, and promotion.
  2. Internal variant-processing jobs run for each declared variant.
  3. Variants move to ready when processing completes.

See background_processing.md for queue ownership, worker details, retry posture, and telemetry.

7. Attach To Your Domain Record

Associate a verified asset with one of your own records:

{:ok, attachment} = Rindle.attach(asset.id, current_user, "avatar")

Detach later with:

:ok = Rindle.detach(current_user, "avatar")

Detach is async by design: the DB change commits first, then an Oban purge job removes storage objects after commit.

8. Querying Attachments and Variants

Once an attachment exists, the most common adopter operation is rendering the asset (and its ready variants) from a controller or LiveView template. Rindle ships two read helpers so you don't write raw Ecto queries inside Phoenix view code:

{avatar, thumbs} =
  case Rindle.attachment_for(current_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 yet

avatar_with_variants =
  Rindle.attachment_for(current_user, "avatar", preload: [:asset, :variants])
# The :preload option REPLACES the default [:asset] preload list rather
# than merging — declare every association you want preloaded.

Rindle.attachment_for/2,3 resolves the most recent attachment for an (owner, slot) pair, tie-broken by inserted_at desc. The owner can be any Ecto schema struct your application defines; the slot is the same string you passed to Rindle.attach/4.

Rindle.ready_variants_for/1 accepts either a %Rindle.Domain.MediaAsset{} struct or a binary asset id. Pending, processing, and failed variants are filtered out — the helper exists precisely to make "render the variants that are safe to display" a one-liner.

Both helpers go through Rindle.repo() so they inherit the same Repo ownership posture documented earlier in this guide.

9. Bang Variants

For happy-path callers that prefer exceptions over {:error, reason} tuples, Rindle ships five bang variants of the lifecycle functions. Each delegates to its non-bang twin and raises Rindle.Error on generic failures (or, for upload!/3, detach!/3, url!/3, and variant_url!/4, raises Ecto.InvalidChangesetError on changeset failures from the non-bang twin):

attachment = Rindle.attach!(asset.id, current_user, "avatar")
# Raises Rindle.Error{action: :attach, reason: :not_found} if asset missing.

:ok = Rindle.detach!(current_user, "avatar")
# Raises Rindle.Error{action: :detach, reason: ...} on 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: :upload, reason: ...} on validation/storage error.

signed = Rindle.url!(MyApp.MediaProfile, asset.storage_key)
# Raises Rindle.Error{action: :url, reason: :delivery_unsupported} if the
# storage adapter does not advertise the :signed_url capability.

thumb_url = Rindle.variant_url!(MyApp.MediaProfile, asset, :thumb)
# Raises Rindle.Error{action: :variant_url, reason: :variant_not_ready}
# if the variant has not finished processing.

Use bangs in scripts, tests, Mix tasks, and controller actions where you want failures to escalate to the supervisor. For user-facing forms where validation errors must render inline, keep using the non-bang twins (Rindle.attach/4, Rindle.detach/3, Rindle.upload/3, Rindle.url/3, Rindle.variant_url/4) and pattern-match on {:ok, value} / {:error, reason}.

Rindle.Error is a documented public exception with :action (atom) and :reason (term) fields — pattern-match on :action to distinguish which bang raised:

try do
  Rindle.url!(MyApp.MediaProfile, key)
rescue
  e in Rindle.Error ->
    Logger.warn("Rindle url! failed: action=#{e.action} reason=#{inspect(e.reason)}")
    nil
end

10. 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.md.

11. Storage with GCS (optional)

GCS resumable upload is an advanced path after the canonical presigned PUT first run is already healthy. If your profile uses Rindle.Storage.GCS, wire adopter-owned MyApp.Goth, MyApp.Finch, bucket CORS, and the signing key, then run mix rindle.doctor and follow storage_gcs.md.

Next Reads