Agora

Getting Started

Install @adonisjs/drive and @adonis-agora/media, configure the library, attach your first file to an entity, generate a conversion, and resolve URLs.

Getting @adonis-agora/media running is three steps: make sure Drive is installed (it owns storage), configure the media library once, then start attaching files. This guide goes from zero to a stored image with a thumbnail.


Prerequisites

  • Node.js 20.6+
  • AdonisJS 7 (@adonisjs/core ^7.3.0)
  • @adonisjs/drive ^3.0.0 or ^4.0.0, installed and configured — media delegates all storage to it. If you don't have it yet: node ace add @adonisjs/drive.
  • (optional) @adonisjs/lucid if you want the persistent lucid store, and sharp if you want image conversions. Both are optional peers, loaded lazily only when selected.

Step 1 — Install and configure

The package ships an ace configure hook, so the recommended flow is:

node ace add @adonisjs/drive   # if you don't already have it
npm i @adonis-agora/media
node ace configure @adonis-agora/media

configure does all the wiring so there is zero manual work:

  1. registers both providers in adonisrc.ts@adonis-agora/media/media_provider (the library) and @adonis-agora/media/dashboard_provider (the management console, whose SPA ships inside this package — there is no separate install);
  2. publishes config/media.ts from a stub;
  3. publishes config/media_dashboard.ts from a stub;
  4. publishes two Lucid migrations — one for the optional lucid media store, one for the resumable-upload session store.

Both migrations are optional. They back the lucid media store and the lucid upload-session store respectively; if you stay on the in-memory equivalents (the default in the stub), delete the ones you don't need. Otherwise run node ace migration:run.

The published config/media.ts works out of the box with the in-memory store and a sharp processor:

config/media.ts
import { defineConfig, stores, processors } from '@adonis-agora/media'

export default defineConfig({
  // disk: 's3', // omit to use Drive's default disk

  store: 'memory',
  stores: {
    memory: stores.memory(),
    lucid: stores.lucid(),
  },

  imageProcessor: processors.sharp(),

  collections: [
    { name: 'avatar', single: true, acceptsMimeTypes: ['image/png', 'image/jpeg'] },
    {
      name: 'gallery',
      conversions: [
        { name: 'thumb', width: 200, height: 200 },
        { name: 'og', width: 1200, eager: true },
      ],
    },
  ],
})

Every key is covered in Configuration.


Step 2 — Import the manager

The provider binds a singleton MediaManager into the container. The idiomatic way to reach it is the service-singleton import — resolved once at boot and ready to use wherever you need it — a controller, a service, a command:

import media from '@adonis-agora/media/services/main'

If you prefer dependency injection, the same singleton is available through the container — @inject() a constructor param typed private media: MediaManager, or resolve it manually with await app.container.make(MediaManager).

MediaManager composes the two layers over a shared storage façade:

AccessorWhat it is
media.librarythe MediaLibrary — owner collections + conversions
media.attachmentsthe AttachmentManager — column attachments + variants
media.disk(name?)escape hatch to the raw underlying Drive disk

Step 3 — Attach a file to a collection

attach writes the bytes to a disk and saves a metadata record. You provide the owning entity (ownerType + ownerId), the collection, and the file. contents is a Buffer or a Readable — so in a real upload you hand it the tmp-file stream.

By default a Readable is buffered into memory once so the library can size it and feed any conversions. But when you pass a known size, the collection defines no conversions, and the disk supports streamed writes (putStream — S3, GCS, the local FS driver), attach pipes the stream straight to the disk with no in-memory buffer. That's the path you want for large originals: hand it the multipart file's size and it never materializes the whole file in memory.

await media.library.attach({
  ownerType: 'Post',
  ownerId: post.id,
  collection: 'downloads',       // a collection with no conversions
  fileName: image.clientName,
  mimeType: image.type!,
  size: image.size,              // known byte count → streamed, not buffered
  contents: createReadStream(image.tmpPath!),
})

When streaming kicks in

All three conditions must hold: contents is a Readable (a Buffer is already in memory), size is provided (S3's putStream cannot write without a content length), and the collection has no conversions (those need the buffered bytes to run through the image processor). Otherwise attach falls back to buffering — always correct, just not zero-copy. When size is omitted, it is read back from the disk after the write.

Wire a route to a controller that loads the owning Post, validates the upload, and streams it into the collection:

start/routes.ts
import router from '@adonisjs/core/services/router'
const PostImagesController = () => import('#controllers/post_images_controller')

router.post('posts/:id/images', [PostImagesController, 'store'])
app/controllers/post_images_controller.ts
import { createReadStream } from 'node:fs'
import Post from '#models/post'
import media from '@adonis-agora/media/services/main'
import type { HttpContext } from '@adonisjs/core/http'

export default class PostImagesController {
  async store({ request, response, params }: HttpContext) {
    const post = await Post.findOrFail(params.id)

    const image = request.file('image', {
      size: '5mb',
      extnames: ['jpg', 'jpeg', 'png', 'webp'],
    })

    if (!image) {
      return response.badRequest({ error: 'An "image" file is required' })
    }

    const record = await media.library.attach({
      ownerType: 'Post',
      ownerId: post.id, // string | number — coerced for you
      collection: 'gallery',
      fileName: image.clientName,
      mimeType: image.type!,
      contents: createReadStream(image.tmpPath!), // Buffer | Readable
    })

    return response.created({
      id: record.id,
      url: await media.library.url(record.id),
    })
  }
}

A few things happen on attach, driven by the collection's config:

  • If the collection has an acceptsMimeTypes whitelist and the file's type isn't on it, a MimeNotAllowedError (E_MEDIA_MIME_NOT_ALLOWED) is thrown before anything is written.
  • fileName (image.clientName above) must be a single safe path segment — a traversal attempt or an absolute path throws UnsafeFileNameError (E_MEDIA_UNSAFE_FILE_NAME) instead of being silently normalized.
  • If the collection is single, the new file replaces any existing media in it (for this owner). The previous file is removed only after the new one is safely written and persisted, so a failed upload leaves the old media intact.
  • The file is written to ${ownerType}/${ownerId}/${collection}/${id}/${fileName} on the resolved disk (per-call disk → collection disk → storage default).
  • Any eager conversions are generated synchronously; lazy ones wait for the first url().

The owner is yours to define

ownerType and ownerId are just strings the library stores and filters on — 'Post', 'User', 'org:42', whatever scheme you like. The library never loads your models; it only records the reference. attach, list and media.library.for(ownerType, ownerId) all accept string | number for ownerId and coerce it to a string for you, so a numeric Lucid id (post.id) can be passed as-is.

Idempotent overwrites with a deterministic id

Each attach normally mints a fresh UUID for the storage key (${ownerType}/${ownerId}/${collection}/${id}/${fileName}). Pass your own id instead and the key becomes deterministic — so re-attaching with the same id overwrites the previous object at the same path rather than orphaning it under a new UUID.

That's exactly what you want inside a retried unit of work — a durable workflow step, a queue job, a webhook handler — where the same logical file may be produced more than once:

// A durable step that re-renders and re-attaches the same export. On a retry the id is
// identical, so the disk overwrites the prior bytes in place — no duplicate object left behind.
await media.library.attach({
  ownerType: 'Invoice',
  ownerId: invoice.id,
  collection: 'pdf',
  id: `invoice-${invoice.id}`,   // fixed key → retry-safe overwrite
  fileName: 'invoice.pdf',
  mimeType: 'application/pdf',
  contents: renderedPdf,
})

You own uniqueness

The id is used verbatim as both the media-record primary key and the storage-key segment. Make it unique for what should be a distinct object (and stable for what should overwrite) — the library does not add entropy on top of it.

Bind an owner once with for()

When you do several operations for the same entity — say a bulk gallery upload — bind it once so you don't repeat ownerType / ownerId on every call:

app/controllers/post_images_controller.ts
async storeMany({ request, response, params }: HttpContext) {
  const post = await Post.findOrFail(params.id)
  const images = request.files('images', { extnames: ['jpg', 'jpeg', 'png', 'webp'] })

  const gallery = media.library.for('Post', post.id) // id may be a string or number

  for (const image of images) {
    await gallery.attach({
      collection: 'gallery',
      fileName: image.clientName,
      mimeType: image.type!,
      contents: createReadStream(image.tmpPath!),
    })
  }

  return response.ok(await gallery.list('gallery'))
}

for() returns a small binding with three methods — attach, attachExisting and list — each pre-filled with that owner:

const gallery = media.library.for('Post', post.id)

await gallery.attach({ collection: 'gallery', fileName, mimeType, contents })

// register an object that is ALREADY on the disk (a finished TUS or direct-S3 upload),
// without reading or rewriting a single byte
await gallery.attachExisting({
  collection: 'gallery',
  key: 'uploads/9f2a/clip.mp4',
  fileName: 'clip.mp4',
  mimeType: 'video/mp4',
})

await gallery.list('gallery')

attachExisting is the zero-copy adoption path: the record simply points at the key you hand it, and the size is read from the disk's metadata (a HEAD, not a download) when you don't pass one. Pass moveIntoLayout: true to have it moved into the library's canonical ownerType/ownerId/collection/id/fileName layout first — that needs a disk with a native server-side move, and the bytes are never streamed through your app to fake one.

For find / delete / url (which work by media id, not owner), use media.library directly.


Step 4 — Resolve URLs and conversions

// original
await media.library.url(m.id)

// a named conversion — generated lazily on first call, then cached on the record
await media.library.url(m.id, 'thumb')

// a signed, expiring URL (presign-capable disks like s3/gcs)
await media.library.signedUrl(m.id, '30m')

// third arg is an options object — sign a named conversion instead of the original
await media.library.signedUrl(m.id, '1h', { conversion: 'thumb' })

// force a download with a chosen filename (a response header baked into the URL)
await media.library.signedUrl(m.id, '1h', {
  contentDisposition: 'attachment; filename="report.png"',
})

The first time you ask for url(id, 'thumb'), the library reads the original off the disk, runs it through the image processor, writes the result next to the original, and records the conversion path so the next call is a pure lookup. You can also force a conversion to exist ahead of time with media.library.ensureConversion(m.id, 'thumb').

signedUrl options

The third argument to signedUrl is a MediaSignedUrlOptions object. conversion picks a variant to sign instead of the original (generated lazily first if absent); everything else — contentType, contentDisposition — is a response header the presigner bakes into the URL, so it applies to whoever follows the link, not to this call. contentDisposition: 'attachment; filename="…"' is what turns a link into a named download.

Asking for a conversion that isn't declared on the collection throws ConversionNotDefinedError. Asking for any conversion when no imageProcessor is configured throws ImageProcessorMissingError. See Collections & Conversions.


Step 5 — List, find and delete

await media.library.list('Post', post.id)            // every collection, ordered
await media.library.list('Post', post.id, 'gallery') // one collection, ordered by `order` asc
await media.library.find(m.id)                               // MediaRecord | null

await media.library.delete(m.id) // removes the original AND every generated conversion from disk

delete is thorough: it removes the original file and every conversion file from their disks, then deletes the metadata row. Deleting an id that doesn't exist is a silent no-op.


Next steps

On this page