Implement a full-featured media library with admin UI for uploading, organizing, and managing audio and image assets with S3-compatible storage, AI-assisted metadata generation, and maintenance tools. Use when the user asks to "add a media library", "build asset management", "implement media uploads", "add image and audio management", "create a content library", or wants centralized media management with AI-powered metadata suggestions.
From recipesnpx claudepluginhub ichabodcole/project-docs-scaffold-template --plugin recipesThis skill uses the workspace's default tool permissions.
references/elysia-drizzle-nuxt.mdreferences/prototypes/browse-mockup.htmlreferences/prototypes/upload-mockup.htmlreferences/technology-agnostic.mdProvides UI/UX resources: 50+ styles, color palettes, font pairings, guidelines, charts for web/mobile across React, Next.js, Vue, Svelte, Tailwind, React Native, Flutter. Aids planning, building, reviewing interfaces.
Fetches up-to-date documentation from Context7 for libraries and frameworks like React, Next.js, Prisma. Use for setup questions, API references, and code examples.
Calculates TAM/SAM/SOM using top-down, bottom-up, and value theory methodologies for market sizing, revenue estimation, and startup validation.
Build a centralized media library system for managing audio and image assets. The library provides upload with server-side processing, browsable/searchable admin UI, AI-assisted metadata generation for images, and maintenance tools for storage hygiene. It's designed as a content authoring foundation — the upstream source that feeds curated content to client applications.
This recipe comes in two flavors:
See references/prototypes/ for interactive HTML prototypes of the admin UI:
browse-mockup.html — Master-detail browse layout with list/grid views,
filters, detail panel, and edit dialogupload-mockup.html — Upload flow with drag-and-drop, AI image analysis,
metadata form, and "AI suggested" field indicatorsOpen in a browser to click through the states.
Admin UI Client Apps
(Browse, Upload, Edit) (Mobile, Web)
│ │
│ REST API │ Presigned URLs
│ (multipart upload, │ (time-limited
│ CRUD, analyze) │ direct-to-storage)
▼ │
┌─────────────────────────────────────┐ │
│ API Server │ │
│ │ │
│ ┌───────────────┐ ┌───────────┐ │ │
│ │ Media Service │ │ AI Service│ │ │
│ │ (upload, list, │ │ (vision │ │ │
│ │ update, etc.) │ │ analysis)│ │ │
│ └──────┬────────┘ └─────┬─────┘ │ │
│ │ │ │ │
│ ▼ ▼ │ │
│ ┌──────────┐ ┌──────────────┐ │ │
│ │ Database │ │ AI Provider │ │ │
│ │ (items, │ │ (vision │ │ │
│ │ tags) │ │ model API) │ │ │
│ └──────────┘ └──────────────┘ │ │
│ │ │ │
│ ▼ │ │
│ ┌────────────────┐ │ │
│ │ S3-Compatible │◄───────────────┼────┘
│ │ Object Storage │ │
│ │ (R2, S3, MinIO) │ │
│ └────────────────┘ │
└─────────────────────────────────────┘
Generic media table with type discriminator. A single media_items table
with a type column (audio, image) and JSONB metadata for
type-specific fields. This avoids premature schema specialization — usage
patterns reveal what deserves dedicated columns. Clear migration path to
normalized per-type tables if needed.
API-mediated uploads (not direct-to-storage). Client uploads to the API server, which validates, processes (resize/convert images, extract audio metadata), and writes to storage. Simpler than presigned upload URLs, allows server-side processing in one pass.
Presigned URLs for all media access. Storage bucket stays private. All access gated through the API. This is the safer default — relaxing to public URLs later is easy, going the other direction is hard.
Flat storage keys. {uuid}.{ext} for audio, images/{uuid}.webp for
images. Category and metadata live in the database, not the storage path.
Avoids coupling storage keys to mutable metadata.
Checksum-based duplicate detection. SHA-256 of file contents prevents duplicate uploads. Unique index on checksum (excluding soft-deleted items).
Soft-delete with deferred cleanup. Deleting marks deleted_at timestamp.
Storage files retained until explicit purge. Prevents accidental data loss.
AI analysis is stateless. The analyze endpoint takes an image, returns metadata suggestions, stores nothing. Separation keeps the responsibility boundary clean and makes re-analysis safe.
| Field | Type | Required | Notes |
|---|---|---|---|
id | UUID | Yes | Primary key |
type | text | Yes | audio or image, immutable after creation |
title | text | Yes | Required at upload |
description | text | No | Optional |
s3_key | text | Yes | Storage key, unique, immutable |
original_filename | text | Yes | Preserved from upload |
file_size | integer | Yes | Bytes |
mime_type | text | Yes | Validated against allowed types |
checksum | text | Yes | SHA-256, unique among non-deleted items |
metadata | JSONB | No | Type-specific fields (see below) |
published | boolean | Yes | Default false |
created_at | timestamp | Yes | Auto-set |
updated_at | timestamp | Yes | Auto-set |
deleted_at | timestamp | No | Soft delete marker |
JSONB metadata shapes:
Audio:
duration: number|null — seconds, extracted from file
Image:
width: number — pixels (after processing)
height: number — pixels (after processing)
thumbnailKey: string — storage key for thumbnail
format: string — output format (e.g., "webp")
altText: string? — accessibility description
Audio metadata is intentionally minimal — duration is the only universally useful field that can be auto-extracted. Application-specific metadata (e.g., categories, transcripts, loop-capable flags) can be added to the JSONB as needed for your domain.
| Field | Type | Required | Notes |
|---|---|---|---|
media_item_id | UUID | Yes | FK → media_items.id, cascade delete |
tag | text | Yes | Freeform string |
Composite primary key on (media_item_id, tag). Tags are freeform — consistent
naming is a discipline concern, not a schema constraint.
checksum WHERE deleted_at IS NULL (duplicate prevention)ids3_keyThe service layer exposes these operations:
| Operation | Input | Output | Notes |
|---|---|---|---|
uploadMedia | File + metadata | MediaItem | Audio: validate → extract duration → hash → store → insert |
uploadImage | File + metadata | MediaItem | Validate → resize → convert WebP → thumbnail → hash → store → insert |
listMedia | Filters, pagination, sort | Items[] + total | Type, search, tags (OR), published filters |
getMediaById | ID | MediaItem | null | Includes tags |
updateMedia | ID + partial metadata | MediaItem | Type-aware JSONB merge, tag replacement |
softDeleteMedia | ID | success | Sets deleted_at, retains storage files |
getPresignedUrl | ID + variant? | URL string | Variant is specifically 'thumb' for thumbnails |
getMediaImageBuffer | ID | Buffer + contentType | Fetches from storage, validates type=image |
getAllTags | — | string[] | Distinct tags from non-deleted items, sorted |
analyzeImage | Image buffer + MIME type | Analysis result | Stateless — no storage, no DB writes |
purgeDeleted | — | Count + errors | Hard-delete soft-deleted items + thumbnails from DB + storage |
findOrphanedKeys | — | { orphanedKeys[], totalR2Keys, totalDbKeys } | Keys in storage with no DB record |
purgeOrphanedKeys | — | Count + errors | Delete orphaned storage objects |
See references/technology-agnostic.md for framework-independent architecture, implementation phases, and adaptation guidance.
Best when:
See references/elysia-drizzle-nuxt.md for exact implementation details with this stack.
Best when:
Images should be converted to WebP on upload. Don't store the original format — process to a consistent format with quality control. This keeps storage predictable and thumbnails uniform.
Checksum on processed buffer, not original. For images, compute SHA-256 on the processed WebP buffer (what's actually stored), not the original upload. Otherwise re-uploading the same image in a different format bypasses duplicate detection.
S3-first, then DB. Upload to storage first, then insert the database record. If the DB insert fails, delete the storage object. The reverse (DB first, then storage) leaves orphaned DB records pointing to nothing.
AI analysis is optional, not a dependency. The upload flow must work perfectly without AI analysis. Analysis is an enhancement that populates form fields — it should never block or gate the upload.
Client-side image optimization before AI analysis. Resize to ~1024px max before sending to the vision API. Large images waste tokens and don't improve analysis quality.
Server-side analysis for existing images. When analyzing images already in storage, fetch from storage on the server side rather than sending from the client. This avoids CORS issues and doesn't require the client to download the full-resolution image.
"AI suggested" indicators clear on user edit. When AI populates a form field, mark it visually. When the user modifies that field, clear the indicator. This tells the user which values they've reviewed vs. which are raw AI output.
Soft-deleted items retain storage files indefinitely. Don't auto-purge. Provide explicit admin tools for purging deleted items and orphaned storage objects.
Audio duration extraction can fail. Some files have missing or corrupted metadata. Allow upload with null duration rather than rejecting the file.
Stored fileSize and mimeType reflect the processed file, not the
original upload. For images, fileSize is the WebP buffer length and
mimeType is always image/webp, regardless of what was uploaded (JPEG,
PNG). This is correct — it matches what's in storage — but can surprise
implementers who expect the original upload metadata.
Purge deleted must also delete thumbnail keys. When purging soft-deleted
image items, delete both s3_key and metadata.thumbnailKey from storage.
Missing the thumbnail leaves orphaned storage objects.
Tags UI differs between upload and edit flows. Upload uses a comma-separated text input (simpler for bulk entry). Edit uses individual tag addition with Enter key / button and per-tag removal with X buttons. These are deliberately different UX patterns for different contexts.
Image preview URLs need explicit lifecycle management. Use
URL.createObjectURL() for local file previews and URL.revokeObjectURL() on
file change and component unmount to prevent memory leaks.
Upload button should be disabled during AI analysis. The canUpload guard
should return false while isAnalyzing is true, preventing upload of
incomplete AI-populated metadata.
R2 SDK v3.729.0+ needs requestChecksumCalculation: 'WHEN_REQUIRED'.
Without this flag, uploads fail with checksum mismatch errors on Cloudflare
R2.