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.

README.md is the narrow quickstart. guides/getting_started.md is the canonical deep adopter guide for the same first-run path. Phase 29 proves that path from a generated package-consumer Phoenix app in two outside-in lanes from installed artifacts: image-only and AV-enabled. Existing adopters upgrading from the pre-v1.4 image-only shape should use guides/upgrading.md 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.

The package-consumer proof posture is intentionally narrow in this README: maintainers prove the installed artifact from a generated app, while adopters follow the same public setup contract described here. The built-artifact lane proves image-only and AV-enabled installs before publish; the published-artifact companion repeats the same generated-app posture against the released package.

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

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. The consumer smoke lane proves this Application.app_dir/2 path from a generated Phoenix app:

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 this in v1.1. The public path is the docs snippet above; the repo-private automation lives in the install smoke harness.

First Run: AV Quickstart

The locked onboarding path is:

  1. mix deps.get
  2. install FFmpeg >= 6.0 from RUNNING.md
  3. declare one kind: :video variant plus the stock poster
  4. run mix rindle.doctor
  5. follow the normal facade-first upload lifecycle

Phase 29 locks that onboarding contract to the package-consumer proof matrix:

The deep guide explains where built-artifact proof ends and published-artifact proof begins without turning this quickstart into a release runbook.

The canonical deep guide expands the same path in guides/getting_started.md. 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. The same public path is proven by the built-artifact install smoke and the canonical adopter lifecycle test.

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 guides/streaming_providers.md.

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 guides/storage_gcs.md.

Next Reads

GSD Hygiene

For local GSD cleanup, run mix gsd.clean. It removes known transient outputs, prunes stale worktree metadata, and reports any remaining .planning/ dirt without deleting tracked planning artifacts.

Use the GSD workflows for the tracked planning lifecycle:

Documentation conventions

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

License

MIT