README.md

# PhxMediaLibrary

[![Hex.pm](https://img.shields.io/hexpm/v/phx_media_library.svg)](https://hex.pm/packages/phx_media_library)
[![Hex Docs](https://img.shields.io/badge/hex-docs-blue.svg)](https://hexdocs.pm/phx_media_library)
[![License](https://img.shields.io/hexpm/l/phx_media_library.svg)](https://github.com/mike-kostov/phx_media_library/blob/main/LICENSE)

A robust, Ecto-backed media management library for Elixir and Phoenix, inspired by [Spatie's Laravel Media Library](https://spatie.be/docs/laravel-medialibrary).

## Features

- **Associate files with Ecto schemas** — Polymorphic media associations via `has_media()` macro
- **Declarative DSL** — Define collections and conversions with a clean macro syntax
- **Collections** — Organize media into named collections with MIME validation, file limits, and single-file mode
- **Image conversions** — Generate thumbnails, previews, and custom sizes (optional — works without libvips)
- **Responsive images** — Automatic srcset generation for optimal loading
- **Multiple storage backends** — Local filesystem, S3, in-memory (for tests), or custom adapters
- **Async processing** — Background conversion processing with Task (default) or Oban
- **LiveView components** — Drop-in `<.media_upload>` and `<.media_gallery>` components that eliminate upload boilerplate
- **LiveUpload helpers** — `use PhxMediaLibrary.LiveUpload` for collection-aware uploads with one line
- **Checksum integrity** — SHA-256 computed on upload, verifiable at any time
- **Composable queries** — `media_query/2` returns an `Ecto.Query` for further composition
- **Phoenix view helpers** — `<.media_img>`, `<.responsive_img>`, and `<.picture>` components

## Installation

Add `phx_media_library` to your dependencies in `mix.exs`:

```elixir
def deps do
  [
    {:phx_media_library, "~> 0.2.0"},

    # Optional: Image processing (requires libvips)
    {:image, "~> 0.54"},

    # Optional: S3 storage
    {:ex_aws, "~> 2.5"},
    {:ex_aws_s3, "~> 2.5"},
    {:sweet_xml, "~> 0.7"},

    # Optional: Async processing with Oban
    {:oban, "~> 2.18"}
  ]
end
```

> **Note:** The `:image` dependency (libvips) is **optional**. PhxMediaLibrary works for file storage (PDFs, CSVs, documents) without it. Image conversions and responsive images require `:image` to be installed. If it's missing, you'll get clear error messages guiding you to install it.

## Configuration

Add the required configuration to your `config/config.exs`:

```elixir
config :phx_media_library,
  repo: MyApp.Repo,
  default_disk: :local,
  disks: [
    local: [
      adapter: PhxMediaLibrary.Storage.Disk,
      root: "priv/static/uploads",
      base_url: "/uploads"
    ]
  ]
```

### Storage Options

#### Local Disk (Default)

```elixir
config :phx_media_library,
  disks: [
    local: [
      adapter: PhxMediaLibrary.Storage.Disk,
      root: "priv/static/uploads",
      base_url: "/uploads"
    ]
  ]
```

#### Amazon S3

```elixir
config :phx_media_library,
  default_disk: :s3,
  disks: [
    s3: [
      adapter: PhxMediaLibrary.Storage.S3,
      bucket: "my-bucket",
      region: "us-east-1"
    ]
  ]

# Configure ExAws credentials
config :ex_aws,
  access_key_id: {:system, "AWS_ACCESS_KEY_ID"},
  secret_access_key: {:system, "AWS_SECRET_ACCESS_KEY"}
```

### Responsive Images (Optional)

```elixir
config :phx_media_library,
  responsive_images: [
    enabled: true,
    widths: [320, 640, 960, 1280, 1920],
    tiny_placeholder: true
  ]
```

### Async Processing with Oban (Optional)

```elixir
config :phx_media_library,
  async_processor: PhxMediaLibrary.AsyncProcessor.Oban
```

## Quick Start

### 1. Run the installer

```bash
mix phx_media_library.install
mix ecto.migrate
```

This generates the `media` table migration with all required fields.

### 2. Add to your Ecto schema

PhxMediaLibrary supports two styles for defining collections and conversions. You can use either — or mix them.

#### Declarative DSL (recommended)

```elixir
defmodule MyApp.Post do
  use Ecto.Schema
  use PhxMediaLibrary.HasMedia

  schema "posts" do
    field :title, :string

    has_media()          # injects has_many :media (all media for this model)
    has_media(:images)   # injects has_many :images (scoped to "images" collection)
    has_media(:avatar)   # injects has_many :avatar (scoped to "avatar" collection)

    timestamps()
  end

  media_collections do
    collection :images, max_files: 20
    collection :documents, accepts: ~w(application/pdf text/plain)
    collection :avatar, single_file: true, fallback_url: "/images/default.png"
  end

  media_conversions do
    convert :thumb, width: 150, height: 150, fit: :cover
    convert :preview, width: 800, quality: 85
    convert :banner, width: 1200, height: 400, fit: :crop, collections: [:images]
  end
end
```

#### Function-based approach

```elixir
defmodule MyApp.Post do
  use Ecto.Schema
  use PhxMediaLibrary.HasMedia

  schema "posts" do
    field :title, :string
    has_media()
    timestamps()
  end

  def media_collections do
    [
      collection(:images),
      collection(:documents, accepts: ~w(application/pdf)),
      collection(:avatar, single_file: true)
    ]
  end

  def media_conversions do
    [
      conversion(:thumb, width: 150, height: 150, fit: :cover),
      conversion(:preview, width: 800, quality: 85)
    ]
  end
end
```

The `has_media()` macro injects a polymorphic `has_many :media` association so you can use standard Ecto preloading:

```elixir
post = Repo.get!(Post, id) |> Repo.preload([:media, :images, :avatar])
```

Collection-scoped variants like `has_media(:images)` add a scoped `has_many` filtered by both model type and collection name.

### 3. Add media to your models

```elixir
# From a file path
{:ok, media} =
  post
  |> PhxMediaLibrary.add("/path/to/image.jpg")
  |> PhxMediaLibrary.to_collection(:images)

# With custom filename and metadata
{:ok, media} =
  post
  |> PhxMediaLibrary.add(upload)
  |> PhxMediaLibrary.using_filename("custom-name.jpg")
  |> PhxMediaLibrary.with_custom_properties(%{"alt" => "My image"})
  |> PhxMediaLibrary.to_collection(:images)

# From a URL
{:ok, media} =
  post
  |> PhxMediaLibrary.add_from_url("https://example.com/image.jpg")
  |> PhxMediaLibrary.to_collection(:images)

# Bang version raises on error
media = PhxMediaLibrary.to_collection!(adder, :images)
```

### 4. Retrieve media

```elixir
# Get all media in a collection
PhxMediaLibrary.get_media(post, :images)

# Get the first media item
PhxMediaLibrary.get_first_media(post, :images)

# Get URLs
PhxMediaLibrary.get_first_media_url(post, :images)
PhxMediaLibrary.get_first_media_url(post, :images, :thumb)
PhxMediaLibrary.get_first_media_url(post, :avatar, fallback: "/default.jpg")

# Get URL for a specific media item
PhxMediaLibrary.url(media)
PhxMediaLibrary.url(media, :thumb)

# Composable Ecto queries
PhxMediaLibrary.media_query(post, :images)
|> where([m], m.mime_type == "image/png")
|> limit(5)
|> Repo.all()
```

## LiveView Components

PhxMediaLibrary ships with drop-in LiveView components that eliminate 150+ lines of upload boilerplate.

### Setup

Add to your `my_app_web.ex`:

```elixir
defp html_helpers do
  quote do
    # ... existing imports
    import PhxMediaLibrary.Components
    import PhxMediaLibrary.ViewHelpers
  end
end
```

### Upload + Gallery in a LiveView

```elixir
defmodule MyAppWeb.PostLive.Edit do
  use MyAppWeb, :live_view
  use PhxMediaLibrary.LiveUpload

  def mount(%{"id" => id}, _session, socket) do
    post = Posts.get_post!(id)

    {:ok,
     socket
     |> assign(:post, post)
     |> allow_media_upload(:images, model: post, collection: :images)
     |> stream_existing_media(:media, post, :images)}
  end

  def handle_event("validate", _params, socket) do
    {:noreply, socket}
  end

  def handle_event("save_media", _params, socket) do
    case consume_media(socket, :images, socket.assigns.post, :images, notify: self()) do
      {:ok, media_items} ->
        {:noreply,
         socket
         |> stream_media_items(:media, media_items)
         |> put_flash(:info, "Uploaded #{length(media_items)} file(s)")}

      {:error, reason} ->
        {:noreply, put_flash(socket, :error, "Upload failed: #{inspect(reason)}")}
    end
  end

  def handle_event("delete_media", %{"id" => id}, socket) do
    case delete_media_by_id(id, notify: self()) do
      :ok -> {:noreply, stream_delete_by_dom_id(socket, :media, "media-#{id}")}
      {:error, reason} -> {:noreply, put_flash(socket, :error, inspect(reason))}
    end
  end

  def handle_event("cancel_upload", %{"ref" => ref}, socket) do
    {:noreply, cancel_upload(socket, :images, ref)}
  end

  # React to media lifecycle events
  def handle_info({:media_added, media_items}, socket) do
    {:noreply, assign(socket, :media_count, length(media_items))}
  end

  def handle_info({:media_removed, _media}, socket) do
    {:noreply, socket}
  end
end
```

### Template

```heex
<form phx-change="validate" phx-submit="save_media">
  <.media_upload
    upload={@uploads.images}
    id="post-images-upload"
    label="Upload Images"
    sublabel="JPG, PNG, WebP up to 10MB"
  />

  <button type="submit">Upload</button>
</form>

<.media_gallery
  media={@streams.media}
  id="post-gallery"
>
  <:item :let={{id, media}}>
    <.media_img media={media} conversion={:thumb} class="rounded-lg" />
  </:item>
  <:empty>
    <p>No images yet. Upload some above!</p>
  </:empty>
</.media_gallery>
```

### `<.media_upload>` Features

- Drag-and-drop with visual feedback (`.MediaDropZone` JS hook)
- Live image previews via `<.live_img_preview>`
- Upload progress bars per entry
- Per-entry error display and cancel buttons
- Full-size and compact layouts
- Dark mode support
- Fully customizable via attrs, slots, and CSS classes

### `<.media_gallery>` Features

- Stream-powered grid (2–6 configurable columns)
- Image thumbnails with delete-on-hover
- Document type icons (PDF, spreadsheet, archive, etc.)
- `:item` and `:empty` slots for custom rendering

### `<.media_upload_button>`

A compact inline variant for embedding upload triggers within forms or tight layouts.

### `PhxMediaLibrary.LiveUpload` Helpers

`use PhxMediaLibrary.LiveUpload` imports these functions into your LiveView:

| Function | Purpose |
|----------|---------|
| `allow_media_upload/3` | Wraps `allow_upload/3` with collection-aware defaults (accept types, max entries, max file size) |
| `consume_media/5` | Consumes uploads and persists via `PhxMediaLibrary.add/2 \|> to_collection/2` |
| `stream_existing_media/4` | Loads existing media into a LiveView stream |
| `stream_media_items/3` | Inserts newly created media into a stream |
| `delete_media_by_id/2` | Deletes a media record and its files |
| `media_upload_errors/1` | Human-readable error strings for an upload |
| `media_entry_errors/2` | Human-readable error strings for an entry |
| `has_upload_entries?/1` | Whether the upload has any entries |
| `image_entry?/1` | Whether an entry is an image (for conditional previews) |
| `translate_upload_error/1` | Extensible error atom → string translation |

### Event Notifications

Both `consume_media/5` and `delete_media_by_id/2` accept a `:notify` option. When set to a pid (e.g. `self()`), lifecycle messages are sent to that process:

- `{:media_added, [Media.t()]}` — after successful upload
- `{:media_error, reason}` — when upload fails
- `{:media_removed, Media.t()}` — after successful deletion

Handle them in your LiveView via `handle_info/2`.

## Collections

Collections organize media and apply validation rules.

```elixir
media_collections do
  # Basic collection
  collection :images

  # MIME type validation
  collection :documents, accepts: ~w(application/pdf application/msword)

  # Single file only (replaces existing on new upload)
  collection :avatar, single_file: true

  # Limit number of files (oldest excess is removed)
  collection :gallery, max_files: 10

  # Custom storage disk
  collection :backups, disk: :s3

  # Fallback URL when collection is empty
  collection :profile_photo, single_file: true, fallback_url: "/images/default-avatar.png"
end
```

## Conversions

Conversions automatically generate derived images when media is added. Requires the `:image` dependency.

```elixir
media_conversions do
  # Simple resize
  convert :thumb, width: 150, height: 150

  # Resize with fit mode
  convert :square, width: 300, height: 300, fit: :cover

  # Width only (maintains aspect ratio)
  convert :preview, width: 800

  # With quality setting
  convert :optimized, width: 1200, quality: 80

  # Convert format
  convert :webp_thumb, width: 150, format: :webp

  # Only for specific collections
  convert :banner, width: 1200, collections: [:images, :gallery]
end
```

### Fit Options

| Mode | Behaviour |
|------|-----------|
| `:contain` | Fit within dimensions, maintaining aspect ratio |
| `:cover` | Cover dimensions, cropping if necessary |
| `:fill` | Stretch to fill dimensions exactly |
| `:crop` | Crop to exact dimensions from center |

## Checksum & Integrity Verification

SHA-256 checksums are computed automatically during upload and stored alongside each media record.

```elixir
# Verify a file hasn't been tampered with or corrupted
case PhxMediaLibrary.verify_integrity(media) do
  :ok -> IO.puts("File is intact")
  {:error, :checksum_mismatch} -> IO.puts("File has been corrupted!")
  {:error, :no_checksum} -> IO.puts("No checksum stored for this media")
end
```

## Responsive Images

Generate multiple sizes for optimal loading across devices.

```elixir
# Enable when adding media
post
|> PhxMediaLibrary.add(upload)
|> PhxMediaLibrary.with_responsive_images()
|> PhxMediaLibrary.to_collection(:images)

# Get srcset attribute
PhxMediaLibrary.srcset(media)
# => "uploads/posts/1/responsive/image-320.jpg 320w, ..."
```

## View Helpers

For standard (non-LiveView) templates, PhxMediaLibrary provides rendering components.

### Simple Image

```heex
<.media_img media={@media} class="rounded-lg" />

<.media_img media={@media} conversion={:thumb} alt="Product image" />
```

### Responsive Image

```heex
<.responsive_img
  media={@media}
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 800px"
  class="w-full h-auto"
  alt="Hero image"
/>
```

### Picture Element (Art Direction)

```heex
<.picture
  media={@media}
  sources={[
    %{media: "(max-width: 768px)", conversion: :mobile},
    %{media: "(min-width: 769px)", conversion: :desktop}
  ]}
  alt="Responsive artwork"
/>
```

## Mix Tasks

### Install

```bash
mix phx_media_library.install
```

### Regenerate Conversions

```bash
mix phx_media_library.regenerate --conversion thumb
mix phx_media_library.regenerate --collection images
mix phx_media_library.regenerate --dry-run
```

### Regenerate Responsive Images

```bash
mix phx_media_library.regenerate_responsive
mix phx_media_library.regenerate_responsive --collection images
```

### Clean Orphaned Files

```bash
# Dry run — see what would be deleted
mix phx_media_library.clean

# Actually delete
mix phx_media_library.clean --force
```

### Generate Custom Migration

```bash
mix phx_media_library.gen.migration add_blurhash_field
```

## Deleting Media

```elixir
# Delete a single media item (removes files from storage too)
PhxMediaLibrary.delete(media)

# Clear all media in a collection
PhxMediaLibrary.clear_collection(post, :images)

# Clear all media for a model
PhxMediaLibrary.clear_media(post)
```

## Custom Storage Adapters

Implement the `PhxMediaLibrary.Storage` behaviour:

```elixir
defmodule MyApp.Storage.CustomAdapter do
  @behaviour PhxMediaLibrary.Storage

  @impl true
  def put(path, content, opts) do
    # Store content at path
    :ok
  end

  @impl true
  def get(path, opts) do
    # Return {:ok, binary} or {:error, reason}
  end

  @impl true
  def delete(path, opts) do
    :ok
  end

  @impl true
  def exists?(path, opts) do
    true
  end

  @impl true
  def url(path, opts) do
    "https://my-cdn.com/#{path}"
  end
end
```

Then configure it:

```elixir
config :phx_media_library,
  disks: [
    custom: [
      adapter: MyApp.Storage.CustomAdapter,
      # Your adapter-specific options
    ]
  ]
```

## Error Handling

Functions that can fail return tagged tuples:

```elixir
case PhxMediaLibrary.to_collection(adder, :images) do
  {:ok, media} ->
    # Success
  {:error, :invalid_mime_type} ->
    # File type not accepted by collection
  {:error, :file_not_found} ->
    # Source file doesn't exist
  {:error, changeset} ->
    # Ecto validation error
end

# Or use the bang version to raise on error
media = PhxMediaLibrary.to_collection!(adder, :images)
```

## Testing

For tests, use the in-memory storage adapter:

```elixir
# config/test.exs
config :phx_media_library,
  repo: MyApp.Repo,
  disks: [
    local: [
      adapter: PhxMediaLibrary.Storage.Memory
    ]
  ]
```

Don't forget to start the memory storage agent in your `test_helper.exs`:

```elixir
{:ok, _} = PhxMediaLibrary.Storage.Memory.start_link()
```

## Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

1. Fork it
2. Create your feature branch (`git checkout -b feature/my-feature`)
3. Commit your changes (`git commit -am 'Add my feature'`)
4. Push to the branch (`git push origin feature/my-feature`)
5. Create a Pull Request

## License

This project is licensed under the MIT License — see the [LICENSE](LICENSE) file for details.

## Acknowledgments

Inspired by [Spatie's Laravel Media Library](https://spatie.be/docs/laravel-medialibrary), bringing its excellent developer experience to the Elixir ecosystem.