nuxt-media-library
Media Library
Media library components and upload utilities for Nuxt, Vue, and custom elements.
Features
- ⛰ Foo
- 🚠 Bar
- 🌲 Baz
Quick Setup
Install the module to your Nuxt application with one command:
npx nuxi module add media-library
That's it! You can now use Media Library in your Nuxt app ✨
Usage
Nuxt installation (Vue components only)
export default defineNuxtConfig({
modules: ['media-library'],
mediaLibrary: {
// Auto-register Vue components from src/runtime/components
components: true,
// Where the Nuxt server reaches the media library (with /v1). Server-side
// only, never sent to browsers; prefer NUXT_MEDIA_LIBRARY_BASE_URL.
baseURL: 'http://media-library.internal/v1',
// Only for direct browser uploads (proxyUploads: false): a public address.
// publicBaseURL: 'https://media.example.com/v1',
// false when your own server/api/media-library/[...].ts serves /manage
// (below). Default true: the module's /manage proxies.
// manageProxy: false,
// Uploads always go through Nuxt server (/api/media-library/uploads/*)
routes: {
createSession: '/uploads/sessions',
getSession: '/uploads/sessions/:id',
uploadPart: '/uploads/sessions/:id/parts/:part',
finalize: '/uploads/sessions/:id/finalize',
abort: '/uploads/sessions/:id/abort',
},
partSize: 8 * 1024 * 1024,
concurrency: 3,
checksum: { algorithm: 'SHA-256', perPart: true, final: true },
// Optional. Sent to browsers too, except credentials: Authorization,
// Cookie, Proxy-Authorization and X-Api-Key stay server-side (only the
// /manage proxies add them, never /delivery). X-Tenant is not needed
// (the media library ignores it); it is still sent when set.
// headers: { 'X-Tenant': 'acme' },
},
})
Nuxt puts the public runtime config in every page, so the module never copies
baseURL or credential headers there. Set the media library's internal
address only server-side (NUXT_MEDIA_LIBRARY_BASE_URL). The Nuxt server
issues no media grants and needs no service token: the MLMedia widgets read
through each service's resolve backend (below).
Nuxt usage
<template>
<MediaLibrary />
</template>
If you use the uploader component in Nuxt, provide config via the Nuxt plugin (see your module/plugin setup) and use:
<template>
<MlFileUpload />
</template>
Reading files through your services (resolve backends)
The MLMedia widgets (MLMediaGallery, MLMediaField, the file picker, the
editor) and the upload queue read files through the service that owns them.
The service decides who may see what; the Nuxt server holds no media
credential and issues no grants.
// nuxt.config.ts (or the service's layer)
export default defineNuxtConfig({
mediaLibrary: {
backends: {
videos: {
upload: { start: '/api/videos/uploads' },
resolve: '/api/videos/media/resolve',
},
},
},
})
<MLMediaGallery v-model="lesson.mediaIds" backend="videos" />
# the videos service (pkg_media[fastapi])
gate = AccessGate(client, can_access=lambda user, ids: videos.visible_media(user, ids))
app.include_router(create_media_router(gate, get_current_user, prefix="/videos/media"))
- Request:
POST {resolve}from the browser, same origin, with the user's session (cookies), body{ "assetIds": ["…"] }. At most 100 ids per request; the widgets split longer lists. - Response:
{ "items": [{ "assetId", "status", "asset", "error" }] }, the shape of the media library's/manage/assets/resolve.statusisok,not_found,forbidden(the service'scan_accessrefused) orerror;assetis set whenok. HLS/DASH links are reloaded beforestreamUrlExpiresInruns out, as before. backendpicks the route: the field, the gallery (also when it places picked files by kind), the picker's already-attached files, the editor (useMLMediaEditor().open(id, { backend })),useMLMediaAssets(ids, { backend })and the upload queue's status polling all read there.- Without
backend, or for a backend withoutresolve, they read through Nuxt's own/api/media-library/manage/assets/resolveas before. An unknown name shows the files as errors with a message naming it. - Still through Nuxt's own
/manageproxy: the picker's library list and its preview of listed files, and the processing stream.
Serving /manage yourself
The module's server proxies forward /api/media-library/manage/* to the
media library with the browser's credentials and your static headers.
To add your own per-request credential, turn off the built-in /manage
proxies (their routes are more specific than a catch-all file and would
answer first; the module warns at startup when it sees a shadowed
catch-all) and serve them from one file:
// nuxt.config.ts
export default defineNuxtConfig({
mediaLibrary: { manageProxy: false },
})
// server/api/media-library/[...].ts
import { createMLCatchAllProxy } from 'nuxt-media-library/server'
export default createMLCatchAllProxy({
requestHeaders: async event => ({ Authorization: `Bearer ${await getAccessToken(event)}` }),
})
- Grant mode is gone:
createMLCatchAllProxyandcreateMLProxyno longer takeauthorizeorissueGrant, and the privatemediaLibrary.serviceTokenconfig is not read. Passing either option throws, so an app that relied on them fails loudly instead of forwarding unchecked. Read files throughresolvebackends instead. /deliverynever gets a credential from any source: not the static credentials (a service token inheaders), not the browser's ownAuthorization,Cookie,Proxy-AuthorizationorX-Api-Key(a signed-in user's token, which the media library refuses), and not one arequestHeadersfactory adds. Grants travel inX-Media-Grants; other non-credential headers such asX-Tenantpass.requestHeadersstill sees the request's cookies, and/manageis unchanged.- With
manageProxy: falsethe module still serves/api/media-library/delivery/**and, whenserviceUploads.proxyis on, the service-upload pass-through. Your catch-all handles everything else under/api/media-library.
Showing media on public pages (embedded media)
Public pages never resolve an asset id. Each service puts the media into its
own response (Python: pkg_media's MediaEmbedder, or StrawberryMedia for
GraphQL), having decided who may see what. The page renders the fields it gets:
interface EmbeddedMedia { // from the service's GraphQL schema (codegen)
assetId: string
kind: 'image' | 'video' | 'audio' | 'document' | 'other'
url: string | null // the file; null when denied or not ready
poster: string | null // the one image to show; set even when denied
sources: { url: string, width: number, height?: number | null }[] // poster sizes
streams: { hls?: string | null, dash?: string | null } | null
width?: number | null; height?: number | null; alt?: string | null
expiresIn: number | null // seconds until the first link expires; null = never
public: boolean // every link tokenless (cache-safe)
}
The module auto-imports the helpers that read it (a structural type,
MLEmbeddedMedia, so any codegen type fits):
| Helper | Gives |
|---|---|
mlMediaPoster(media, fallback?) |
the image to show, else fallback |
mlMediaSrcset(media) |
srcset from media.sources, or undefined |
mlPlayableSource(media, { preferFile? }) |
{ src, type: 'hls' | 'progressive' }: the HLS ladder, else the file; null when locked or not ready |
mlIsPlayableVideo(media) |
a video the viewer can play now |
useMLMediaRenewal(media, renew, { marginSeconds?, retrySeconds? }) |
calls renew 5 minutes (default) before tokened links expire, and again every 30 s (default) while it fails; browser only |
<script setup lang="ts">
const { data: item } = await useAsyncData('article', fetchArticle)
// Re-fetch before the links expire and swap in only `media`.
useMLMediaRenewal(() => item.value?.media, async () => {
const fresh = await fetchArticle()
if (item.value && fresh?.media) item.value = { ...item.value, media: fresh.media }
})
const source = computed(() => mlPlayableSource(item.value?.media))
</script>
<template>
<!-- `sizes` is the box's widest rendered width. -->
<img
:src="mlMediaPoster(item?.media, '/placeholder.jpg')"
:srcset="mlMediaSrcset(item?.media)"
sizes="(min-width: 1024px) 420px, 100vw"
alt=""
loading="lazy"
>
<!-- A video: the app's own player (hls.js for type 'hls'). -->
<AppVideoPlayer v-if="source" :src="source.src" :type="source.type" :poster="item?.media?.poster" />
</template>
- Locked or missing:
urlandstreamsare null; showposter(and a paywall). No call to the media library can do better. - Renewal: tokened links expire (
expiresIn).useMLMediaRenewalfetches the record again before then; swap in only the newmediaand resume the player at its playhead (key the player on its source). - SEO: pass
media.poster(and the service's embedded share image) to your SEO helper as URLs; the HTML rendered on the server then carries them. - Caching: a response with a tokened link is per user (
public: false); don't cache it at the edge. useMLAssetPreview,<MLPreviewProvider>andv-assetresolve ids in the browser through/delivery. They are deprecated (a warning in dev) and go with/delivery; so do read grants (below).
Design and rulings: docs/design/embedded-media.md.
Moving a service and its layer over, step by step:
docs/design/embedded-media-migration-guide.md.
Showing assets of several services: read grants
Deprecated with
/delivery: public pages show embedded media instead (see above), and no grant is needed.
Every link comes from the media library; each service decides only for its
own assets by returning a read grant (mlg_…, e.g. the Python client's
client.grants.read(ids)) with the data that references them. Hand the
grants to the module and every preview resolve sends them in one request:
const { addMediaGrants } = useMLMediaGrants()
const lesson = await $fetch('/api/lessons/42') // course service
const profile = await $fetch('/api/profile') // profile service
addMediaGrants(lesson.mediaGrant, profile.mediaGrant) // "mlg_…" or { grant, expiresAt }
- Live grants (up to 10, latest expiry first) go in
X-Media-GrantsonPOST /api/media-library/delivery/assets/resolve; the proxy forwards them. Expired ones are dropped. - Assets no grant lists come back
forbiddenfrom the server and are shown as broken (isBroken(id)), as before; they are resolved again whenever grants are added. - Grants live in the browser only, never in server-side state.
Uploads started by your backend services
When a backend service starts the upload (the Python client's
create_upload_router), the service creates the session and returns a grant
for it, and useMLServiceUpload sends the parts. Where the parts go is the
app's decision, not the service's:
// nuxt.config.ts
export default defineNuxtConfig({
mediaLibrary: {
// server side only: set NUXT_MEDIA_LIBRARY_BASE_URL (e.g. http://media-library.internal/v1)
serviceUploads: { proxy: true }, // parts go through /api/media-library/service-uploads
// or, direct to the media library (needs CORS for the site):
// serviceUploads: { baseURL: 'https://media.example.com/v1' },
},
})
const { upload, progress } = useMLServiceUpload()
const { sessionId, assetId } = await upload(file, {
start: '/api/videos/uploads',
body: { profile: 'lesson-video', context: { lessonId } },
})
await $fetch(`/api/videos/lessons/${lessonId}/video`, { method: 'PUT', body: { sessionId, assetId } })
- The upload base is, in order:
upload(file, { baseURL }),useMLServiceUpload({ baseURL }),serviceUploads.baseURL(NUXT_PUBLIC_MEDIA_LIBRARY_SERVICE_UPLOAD_BASE_URL), the pass-through route whenserviceUploads.proxyis on, and last theuploadBaseUrlan older service returns. With none,uploadfails before any part is sent. - The pass-through route (
serviceUploads.proxy: true) forwards only a session'sGET, partsPUT,finalizeandabortto the privatemediaLibrary.baseURL. It requires the browser'sBearer mlg_…grant and forwards it andX-Tenantuntouched (the staticX-Tenantwhen the browser sends none, and only if one is configured). It never adds static auth headers, drops cookies, and streams request bodies. Mount it yourself withcreateMLServiceUploadsProxy()fromnuxt-media-library/serverfor another path.
One layer per service, widgets by backend name
Each service can ship a Nuxt layer that registers its upload backend and serves its own routes (start, refresh, attach, data) to its service. Nuxt merges the layers, so every widget on the site can use any backend by name.
// itq-videos-layer/nuxt.config.ts
export default defineNuxtConfig({
modules: ['nuxt-media-library'],
mediaLibrary: {
backends: {
videos: {
upload: { start: '/api/videos/uploads' }, // refresh defaults to `${start}/:sessionId/grant`
claim: '/api/videos/uploads/:sessionId/attach', // pkg_media on_attach
resolve: '/api/videos/media/resolve', // pkg_media create_media_router
},
},
},
})
// the site
export default defineNuxtConfig({
extends: ['itq-videos-layer', 'itq-courses-layer'],
modules: ['nuxt-media-library'],
mediaLibrary: { serviceUploads: { proxy: true } }, // where parts go, for every backend
})
<MLMediaGallery
v-model="lesson.mediaIds"
backend="videos"
profile="lesson-video"
:service-body="{ context: { lessonId } }"
@uploaded="(assetId, role, sessionId) => attach(assetId, sessionId)"
/>
backendonMLMediaGallery,MLMediaFieldand the file picker (anduseMLMediaUpload): a name inmediaLibrary.backends.serviceis its older name and still takes{ start }for a one-off;backendwins. Without either, widgets upload through Nuxt's own route as before; an unknown name fails that upload with a message naming the missing backend.- Each backend has a
kind:media-library(the default) ormedia-kit. This version uploads only tomedia-librarybackends; amedia-kitone fails the upload with a clear message. - With
claim, the upload queue attaches files whose page was left before the widget attached them (POST { assetId, profile }; 409 counts as attached). The tray shows a refusal with "Add again". mediaLibrary.serviceUploads.backendsstill works: each entry becomes amedia-librarybackend with thatuploadand noclaim.- The start request carries the file (
mimeType,sizeBytes,filename),profile(each role's, in a gallery with roles) andservice-body. The service's profile decides kind, visibility and processing, sovisibility,processing-configandcollection-nameare not used withservice. uploadedalso gives the upload'ssessionId(gallery:(assetId, role, sessionId), field:(assetId, sessionId)), including files uploaded from the picker and chosen. Send both to the service's attach endpoint: itsclaim_uploadneeds them.- Processing progress uses the processing stream and, when it is not
available, polls the backend's
resolveroute (Nuxt's own route without one), so the service'scan_accessmust let the user see a file they just uploaded. - There is no default backend: each layer's widgets name theirs. The module only stores the paths; each layer's own routes reach its service.
File meta from the widgets
MLMediaField and MLMediaGallery report what they know about their files:
name, extension, MIME type, kind, size, duration, width, height and pages.
<MLMediaField v-model="lesson.videoId" v-model:meta="videoMeta" @meta="m => console.log(m?.durationSeconds)" />
<MLMediaGallery v-model="post.mediaIds" v-model:meta="mediaMeta" @meta="(assetId, m) => …" />
v-model:metais output only:MediaFileMeta | nullon the field, a map by asset ID on the gallery (every role).@metafires per file that changed, with null when it left.- A file uploaded in this tab is read in the browser right away
(
source: 'local', duration and size from a hidden video, audio or image element). The media library's data replaces it once loaded (source: 'server'); values it has no rendition for yet stay local. - A file needs an asset ID before it has meta: during the byte upload it has none yet.
useMLMediaMeta(ids, getAsset)gives the same map for your own lists.
Permalinks
A permalink is a stable public address {media host}/p/{name} for a
public asset (server docs/api/permalinks.md). Store the name, e.g.
users/42/avatar, and re-point it when the file changes:
const { set, get, remove } = useMLPermalinks()
const link = await set('users/42/avatar', assetId) // { name, assetId, url, createdAt, updatedAt }
await get('users/42/avatar') // null when unknown
- The media library takes a service token only. The module's
/api/media-library/manage/permalinks/{name}proxy works whenmediaLibrary.headerscarries one. Otherwise callPUT/GET/DELETE {baseURL}/manage/permalinks/{name}from your server (permalinkPath(name)from/server). isValidPermalinkNamechecks a name the way the server does;permalinkUrl(mediaHost, name)builds the public address.
Error messages, in the editor's language
When a backend refuses a request it answers { detail, reason }: reason
is a stable code (py_client UploadRejected: too_large, mime_type,
context_mismatch, …), detail English text. The widgets show, first that
applies:
- the text for a known
reason, in the editor's language; detail(a 422's firstmsg);- the backend's inner
message, then the status text; - the place's own fallback ("Upload failed", "Could not add the file", …).
The body is read whether the module's own proxy streamed it (err.data) or
an app proxy rethrew it with h3 createError({ data }) (err.data.data).
The raw "[POST] url: 400" text is never shown.
The language is the app's @nuxtjs/i18n locale, else mediaLibrary.locale;
its language part picks the texts (ar-SA → ar). Built in: en and ar.
An app can change any text, or add a language:
mediaLibrary: {
locale: 'ar', // without @nuxtjs/i18n
errorTexts: {
ar: {
reasons: { too_large: 'حجم هذا الملف أكبر من المسموح.' },
fallbacks: { upload: 'فشل رفع الملف' },
},
},
},
A reason with no text in the editor's language uses the English one, then
detail. mediaErrorMessage(err, fallback, { locale, texts }) and
mlErrorMessage(err, fallback) are exported for your own pages.
Vue installation (non-Nuxt)
npm i media-library
import { createApp } from 'vue'
import MediaLibraryPlugin from 'media-library/vue'
const app = createApp(App)
app.use(MediaLibraryPlugin, {
chunkUploader: {
// Use a same-origin proxy (recommended) or set baseURL for direct calls
baseURL: '',
routes: {
createSession: '/api/media-library/uploads/sessions',
getSession: '/api/media-library/uploads/sessions/:id',
uploadPart: '/api/media-library/uploads/sessions/:id/parts/:part',
finalize: '/api/media-library/uploads/sessions/:id/finalize',
},
partSize: 8 * 1024 * 1024,
concurrency: 3,
checksum: { algorithm: 'SHA-256', perPart: true, final: true },
headers: {},
},
})
<template>
<MediaLibrary />
<MlFileUpload />
</template>
Custom element via CDN
Load the prebuilt custom element bundle (IIFE):
<script src="/dist-ce/media-library.custom-element.js"></script>
<media-library></media-library>
<ml-file-upload></ml-file-upload>
Global config:
<script>
window.MediaLibraryConfig = {
chunkUploader: {
baseURL: '',
routes: {
createSession: '/api/media-library/uploads/sessions',
getSession: '/api/media-library/uploads/sessions/:id',
uploadPart: '/api/media-library/uploads/sessions/:id/parts/:part',
finalize: '/api/media-library/uploads/sessions/:id/finalize',
},
partSize: 8388608,
concurrency: 3,
checksum: { algorithm: 'SHA-256', perPart: true, final: true },
headers: {},
}
}
</script>
Per element:
<ml-file-upload
chunk-uploader='{"baseURL":"","routes":{"createSession":"/api/media-library/uploads/sessions","getSession":"/api/media-library/uploads/sessions/:id","uploadPart":"/api/media-library/uploads/sessions/:id/parts/:part","finalize":"/api/media-library/uploads/sessions/:id/finalize"},"partSize":8388608,"concurrency":3,"checksum":{"algorithm":"SHA-256","perPart":true,"final":true},"headers":{}}'
></ml-file-upload>
Developer Guide
Nuxt development (module)
# Install dependencies
npm install
# Generate type stubs and prepare the playground
npm run dev:prepare
# Run the playground (Nuxt module usage)
npm run dev
Use the playground to test module options and auto-registered components.
SSR notes (Nuxt)
If a component uses browser-only APIs, use one of these patterns:
if (process.client) {
// window/document usage
}
import { onMounted } from 'vue'
onMounted(() => {
// safe to access window/document here
})
<ClientOnly>
<MediaLibrary />
</ClientOnly>
Or make it client-only by naming the file MyComponent.client.vue.
Vue development (library)
# Build the Vue entry and type declarations
npm run prepack
Import from media-library/vue in a Vue app to verify component behavior.
Custom element development (CE)
# Build the standalone custom element bundle
npm run build:custom-element
Load dist-ce/media-library.custom-element.js in any HTML page and use
<media-library></media-library> to verify the output.
Tests
npm run lint
npm run test
npm run test:watch
npm run test:types
Version History
| Version | Released | Vue | License |
|---|---|---|---|
| 0.0.63 | ^3.0 | MIT | |
| 0.0.62 | ^3.0 | MIT | |
| 0.0.61 | ^3.0 | MIT | |
| 0.0.60 | ^3.0 | MIT | |
| 0.0.59 | ^3.0 | MIT | |
| 0.0.58 | ^3.0 | MIT | |
| 0.0.57 | ^3.0 | MIT | |
| 0.0.56 | ^3.0 | MIT | |
| 0.0.55 | ^3.0 | MIT | |
| 0.0.54 | ^3.0 | MIT | |
| 0.0.53 | ^3.0 | MIT | |
| 0.0.52 | ^3.0 | MIT | |
| 0.0.51 | ^3.0 | MIT | |
| 0.0.50 | ^3.0 | MIT | |
| 0.0.49 | ^3.0 | MIT | |
| 0.0.48 | ^3.0 | MIT | |
| 0.0.44 | ^3.0 | MIT |