Media (media.ts)
Reference for standard media management — uploads, intelligent processing, folder structures, and CRUD.
On this page
The Media API provides a centralized interface for managing all assets within SveltyCMS. It handles multi-part uploads, intelligent deduplication, virtual folder organization (logical folderId moves), and DAM features (analytics, versioning, bulk download, secure sharing).
⚡ Quick Reference
| Feature | HTTP Endpoint | Method | Permission Required |
|---|---|---|---|
| List Media | /api/media |
GET |
media:read |
| Get Manifest | /api/media/{id} |
GET |
media:read |
| Upload Media | /api/media or /api/media/upload |
POST |
media:write |
| Stream Upload | /api/media/stream |
POST |
media:write |
| Move Media | /api/media/move |
POST |
media:write |
| Update Metadata | /api/media/{id} |
PATCH |
media:write |
| Delete Media | /api/media/{id} |
DELETE |
media:delete |
| Bulk Download | /api/media/bulk-download |
GET/POST |
media:read |
| Batch Process | /api/media/process |
POST |
media:write |
| Batch Resize/Filter | /api/media/batch-process |
POST |
media:write |
| Video Transcode | /api/media/transcode |
POST |
media:write |
| Analytics | /api/media/analytics |
GET |
media:read |
| Version List | /api/media/version/{id}/list |
GET |
media:read |
| Version Compare | /api/media/version/{id}/compare |
GET |
media:read |
| Version Upload | /api/media/version/{id}/upload |
POST |
media:write |
| Version Restore | /api/media/version/{id}/restore |
POST |
media:write |
| Create Share | /api/media/share/{id} |
POST |
media:write |
| Access Share | /api/media/share?id=&token= |
GET |
Public (token) |
| Revoke Share | /api/media/share/{id}/{token} |
DELETE |
media:write |
| References Scan | /api/media/references/{id} |
GET |
media:read |
| Status Sync | /api/media/sync-status |
POST |
media:write |
| Lifecycle GC | /api/media/cleanup |
POST |
media:delete |
| Upload Instructions | /api/media/upload-instructions |
POST |
media:write |
| Direct Complete | /api/media/direct-upload/complete |
POST |
media:write |
| TUS Create | /api/media/tus |
POST |
media:write |
| TUS Continue | /api/media/tus/{id} |
HEAD/PATCH/DELETE |
media:write |
| Job Status | /api/media/jobs/{id} |
GET |
media:read |
Byte delivery and on-demand image transforms live on
/files/**?w=&h=&q=&fmt=— see §5 Delivery — On-Demand Image Transforms.
1. Asset Management
Uploading Assets
SveltyCMS supports single and bulk uploads via standard multipart/form-data.
Standard endpoint: POST /api/media (or POST /api/media/upload)
Payload: files field (array or single stream), optional folder field (virtual folder ID or "global").
Streaming endpoint (large payloads): POST /api/media/stream
Payload: Same multipart/form-data shape; parsed via streaming-upload.ts with backpressure-safe chunk handling. The folder field is captured before file parts are processed.
15 MB route cap. Both upload routes are bounded by the dispatcher’s API_MAX_BODY_SIZE_BYTES (15 MB) — the same ceiling for Content-Length bodies and Transfer-Encoding: chunked bodies. The streaming route hands the dispatcher’s counted stream through undrained, so multipart parsing stays incremental; an oversize part aborts incrementally with 413 PAYLOAD_TOO_LARGE instead of buffering to the parser’s multi-GiB defaults.
Client-Side Routing (upload-client.ts)
The admin UI does not call these endpoints directly for every upload. uploadMediaFiles() in src/utils/media/upload-client.ts auto-routes:
| Condition | Route |
|---|---|
| Any single file ≥ 10 MB, or total batch ≥ 10 MB | POST /api/media/stream |
| Otherwise | SvelteKit form action (?/upload or /mediagallery?/upload) |
The 10 MB routing threshold is a client heuristic below the 15 MB server ceiling; large-payload streaming remains bounded by the route cap above.
Used by mediagallery/+page.svelte and upload-media/local-upload.svelte.
Folders & Structure
Virtual folders organize the asset library without relocating physical files on disk or object storage. Each media row stores an optional folderId pointing at a system virtual folder (or null for media root).
| Concern | Mechanism |
|---|---|
| Folder CRUD | GET/POST/PATCH/DELETE /api/system-virtual-folder (media:read / media:write / media:delete) |
| List by folder | GET /api/media?folderId={id}&recursive=true\|false |
| Upload into | Multipart field folder (virtual folder ID, or "global" for root) |
| Move into | POST /api/media/move (updates folderId only — virtual move) |
Folder context on upload is resolved server-side by MediaService.saveMedia().
The folder endpoints are part of the media capability surface: media:read for reads,
media:write for create/reorder/rename, and media:delete for deletion. The built-in
Editor and Developer roles carry all three; a role with only media:read can browse
folders but not mutate them.
Move Media (Virtual Folder)
Endpoint: POST /api/media/move
Permission: media:write
Handler: handleMediaMove in handlers/media.ts
SDK: LocalCMS → media.move(fileIds, targetFolderId, { tenantId })
Adapter: dbAdapter.media.files.move(fileIds, targetFolderId?, tenantId?)
Moves one or many assets into a virtual folder (or back to media root). Does not rewrite storage paths, invalidate share tokens, or reprocess thumbnails.
Payload:
{
"fileIds": ["uuid-1", "uuid-2"],
"targetFolderId": "folder-uuid-or-null"
}
| Field | Type | Description |
|---|---|---|
fileIds |
string[] (required) |
Non-empty list of media IDs to move |
targetFolderId |
string \| null |
Destination virtual folder ID. null, "", "root", or "global" → root |
Success response:
{
"success": true,
"data": {
"movedCount": 2,
"fileIds": ["uuid-1", "uuid-2"],
"targetFolderId": "folder-uuid"
}
}
Client helper: moveMediaToFolder() in src/utils/media/media-dnd.ts posts this endpoint and dispatches a document-level mediaMoved event so the gallery can refresh optimistically.
Admin UI — drag & drop
The Media Gallery treats the sidebar folder tree and breadcrumb ancestors as equivalent drop targets for the same multi-id payload:
| Source | Drop target | Behavior |
|---|---|---|
media-grid.svelte |
Sidebar (media-folders.svelte) |
Full virtual tree + Media Root |
media-table.svelte |
Breadcrumbs (+page.svelte) |
Ancestor path segments + Media Gallery root |
| Multi-select (Windows-style) | Either target | Dragging any selected item moves the whole set |
- Custom MIME:
application/x-sveltycms-media-ids({ ids: string[] }) - Shared helpers:
beginMediaDrag,resolveMediaDragIds,hasMediaDraginmedia-dnd.ts - Mobile: limited sidebar space — prefer breadcrumb drop, or select items and tap a parent crumb to move
Folder tree CRUD remains on /api/system-virtual-folder (create/rename/reorder/delete folders).
2. Media Processing
Auto-Deduplication
The media engine calculates high-entropy hashes (SHA-256) for every upload. If an identical file already exists in the system, SveltyCMS will automatically link the new metadata to the existing physical file, saving storage space.
Asynchronous Operations
For long-running tasks (AI tagging, batch transforms, heavy ingestion), the API returns a 202 Accepted status with an OperationID.
Endpoint: GET /api/media/jobs/{id}
Returns:
pending: Job is in queue.processing: Job is currently executing.completed: Job finished successfully.failed: Job encountered an error (includes error details).
Batch Processing (processType=batch)
Apply one transformation set to many assets in a single request.
Endpoint: POST /api/media/process
Permission: media:write
Handler: handleMediaProcess in handlers/media.ts (case "batch")
Payload (multipart/form-data):
processType—"batch"mediaIds— JSON array of media IDs, e.g.["uuid-1", "uuid-2"]options(optional) — JSON instructions, same shape as the manipulation payload below
100-id ceiling (MAX_BATCH_PROCESS_IDS). A mediaIds list longer than 100 entries is rejected with 400 INVALID_BATCH_SIZE — the check runs before any asset is touched, because each id otherwise feeds a serial manipulateMedia() loop downstream (payload-exhaustion guard). The cap mirrors the sibling surfaces MAX_BULK_DOWNLOAD_IDS (bulk download), MAX_BATCH_SIZE (/api/token/batch) and MAX_USER_BATCH_SIZE (/api/user/batch). Malformed JSON or a non-array mediaIds returns 400 INVALID_BATCH_IDS.
Batch Resize & Filters (/api/media/batch-process)
Applies one uniform transform to a selection (the gallery’s batch-processor.svelte panel). Unlike process, the payload is JSON and the result reports per-asset outcomes instead of aborting the batch.
Endpoint: POST /api/media/batch-process
Permission: media:write · Handler: handleMediaBatchProcess in handlers/media.ts
{
"ids": ["uuid-1", "uuid-2"],
"operation": "resize",
"params": { "width": 320 }
}
operation |
params |
Applied manipulation |
|---|---|---|
resize |
{ width } — integer 32–4096 |
resize: { width } with fit: inside + no upscaling |
filter |
{ filterPreset } |
vivid · bw · sepia · dramatic |
watermark |
{ text } (≤120 chars) |
top-start text watermark at 24 px |
Limits and gates: ≤100 ids, each asset passes the publish-state gate (409 MEDIA_REFERENCED_BY_PUBLISHED_CONTENT), and the file is overwritten through the same Sharp pipeline as the image editor. A failed asset does not stop the others:
{ "success": true, "data": { "processed": 1, "failed": 1, "results": [ … ] } }
Video Transcoding (/api/media/transcode)
Produces adaptive HLS (master playlist + per-resolution variant playlists and segments) or a single faststart MP4 from a stored video asset.
Endpoint: POST /api/media/transcode
Permission: media:write · Handler: handleMediaTranscode in handlers/media-transcode.ts
{ "mediaId": "uuid", "format": "hls", "resolutions": ["1080p", "720p", "480p"], "bitrate": "auto" }
format—hls(default) ormp4;resolutions⊆1080p | 720p | 480p | 360p;bitrate—autoor<n>k.- Responds
202immediately and records progress on the asset:metadata.transcode = { status: processing | ready | failed }. Playback URLs land onmetadata.hls.master/metadata.mp4.url. - Requires
ffmpegon the serverPATH(orFFMPEG_PATH). Without it the endpoint fails closed with503 FFMPEG_UNAVAILABLE— it never pretends a job started. - Videos above 512 MB are rejected with
413 FILE_TOO_LARGE; non-video assets with400 NOT_A_VIDEO. - Only the
media:writeholder’s tenant is read/written (tenantIdscope on every lookup).
Admin UI: the Transcode button in media-details-modal.svelte opens transcode-modal.svelte; the details modal mounts hls-video-player.svelte when metadata.hls.master is present and falls back to a direct <video src> otherwise.
Image Manipulation API
SveltyCMS provides a dedicated endpoint for server-side image baking.
Endpoint: POST /api/media/manipulate/{id}
Payload: JSON object with a manipulations instruction set. A body without that property is 400.
{
"manipulations": {
"rotation": 90,
"flipH": true,
"crop": { "x": 10, "y": 10, "width": 500, "height": 500 },
"filters": { "brightness": 10, "contrast": 5 },
"focalPoint": { "x": 45, "y": 60 },
"saveBehavior": "overwrite"
}
}
focalPoint is a 0–100 percent. The server stores it on metadata.focalPoint for both a new variant and an overwrite.
Focal Point & Aspect Preview
SveltyCMS supports focal point metadata on images — a percentage coordinate ({ x, y } from 0–100) marking the most important visual area. This is used by frontend rendering (CSS object-position) or server-side cropping tools to ensure the subject remains visible regardless of container aspect ratio.
Update focal point (lightweight, metadata-only):
PATCH /api/media/{id}
Content-Type: application/json
{ "metadata": { "focalPoint": { "x": 35, "y": 60 } } }
Aspect Ratio Preview — A companion UI (available via the Focal Point plugin) shows how an image crops to 16:9, 1:1, 4:3, and other ratios with the focal point as the visual anchor. See the Focal Point Plugin documentation.
Data model: CmsMediaMetadata.focalPoint?: { x: number; y: number } — stored in media_files.metadata, returned in all media GET responses.
3. The Mechanics
Storage Adapters
The Media API is storage-agnostic. It can be configured to use local disk storage, AWS S3, or Cloudflare R2 through the MediaAdapter interface.
4. DAM — Digital Asset Management
Bulk Download
Download multiple media files as a single TAR.GZ archive.
Endpoint: GET /api/media/bulk-download?id={id1}&id={id2}&…
Cap: at most 100 ids per request (MAX_BULK_DOWNLOAD_IDS) — an over-limit request returns 400 INVALID_BATCH_SIZE.
Returns: application/gzip binary stream with Content-Disposition: attachment.
Admin UI: When one or more assets are selected in the Media Gallery, the selection toolbar exposes a Download Archive button that builds the query string from selectedFiles and triggers a browser download.
Storage Analytics
Retrieve storage breakdowns, insights, and growth trends.
Endpoint: GET /api/media/analytics
Returns: JSON wrapped in { success: true, data: { … } } with:
total— file count, byte size,formattedSizebyType— top 5 types by storage shareinsights— actionable cards (large files, old files, dominant types)trends— last 12 months of upload growthquota— used/available bytes, percentage,healthy|warning|criticalstatus
Admin UI: Dashboard widget media-storage-analytics/index.svelte polls this endpoint every 60 seconds. Add it from the dashboard widget picker.
Version History
Track, compare, and restore file versions over time.
| Operation | Endpoint | Notes |
|---|---|---|
| List | GET /api/media/version/{id}/list |
Returns versions array and stats |
| Compare | GET /api/media/version/{id}/compare?from=1&to=2 |
Hash diff, size delta, field-level changes |
| Upload new | POST /api/media/version/{id}/upload |
multipart/form-data with file field |
| Restore | POST /api/media/version/{id}/restore |
JSON { versionNumber } |
| Create | POST /api/media/version/{id} |
Creates version record from uploaded binary |
Admin UI: The Versions tab in media-details-modal.svelte provides upload, restore, per-version download, and a Compare Versions panel (From/To selects + diff results) when ≥2 historical versions exist.
Compare response shape:
{
"success": true,
"data": {
"fromVersion": 1,
"toVersion": 2,
"contentChanged": true,
"metadataChanged": false,
"sizeDifference": 4096,
"changes": [{ "field": "content", "type": "modify", "oldValue": "…", "newValue": "…" }]
}
}
Secure Share Links
Generate expiring, password-protected share links for media assets. See also Media Sharing Endpoint.
| Operation | Endpoint | Payload |
|---|---|---|
| Create | POST /api/media/share/{mediaId} |
{ expiryHours, password? } |
| Access | GET /api/media/share?id={mediaId}&token={token}&password={pw} |
Query only; public with valid token |
| Revoke | DELETE /api/media/share/{mediaId}/{token} |
— |
| Extend | PATCH /api/media/share/{mediaId}/{token} |
{ expiryHours } |
Admin UI: The Share tab in media-details-modal.svelte generates links with configurable expiry and optional password, lists active tokens, and supports one-click revocation.
5. Delivery — On-Demand Image Transforms
The byte-serving route is GET /files/{tenantId}/{hash}/original/{file} (not an /api/media/* endpoint). It streams originals with Range/206/416 support, weak ETag + 304 revalidation and Cache-Control: immutable, and it serves cached on-demand variants when the request asks for a size.
Added 2026-09-22 —
w/h/q/fmtwere previously ignored (full original under any URL). Full architecture and fallback matrix: Media System → On-Demand Image Transforms.
| Param | Aliases | Values | Notes |
|---|---|---|---|
w |
— | dimension ladder step (16 … 3840) |
Snapped down to the nearest step; w alone keeps aspect ratio |
h |
— | dimension ladder step (16 … 3840) |
Snapped down; with w it is a bounding box (fit: inside, no upscaling) |
q |
— | quality ladder step (50 … 90) |
Snapped to the nearest step, default 82 |
fmt |
format |
webp | avif | jpeg/jpg | png |
Explicit format; any other value rejects the transform (never a faked content type) |
Accept |
— | image/avif, image/webp, image/jpeg |
Used in q= order only when fmt/format is absent; wildcards keep the source format |
The ladder values are the only accepted dimensions (16, 24, 32, 48, 64, 96, 128, 160, 192, 240, 320, 384, 480, 640, 768, 960, 1280, 1600, 1920, 2560, 3840, capped by MAX_OUTPUT_DIMENSION); requesting anything else is normalised down or ignored — an arbitrary resolution can never be allocated or cached.
GET /files/global/9f2c…/original/hero-9f2c….jpg?w=320 # 320 px wide, source format
GET /files/global/9f2c…/original/hero-9f2c….jpg?w=1280&fmt=avif
GET /files/global/9f2c…/original/hero-9f2c….jpg?w=300 # normalised to 240 px
GET /files/tenant-a/9f2c…/original/hero-9f2c….jpg?sig=…&exp=…&w=640 # signatures still required
Responses
| Header | Value |
|---|---|
Content-Type |
Variant format (image/webp, image/avif, image/jpeg, image/png) |
ETag / 304 |
Weak variant ETag — repeat requests revalidate without re-encoding |
Cache-Control |
public, max-age=31536000, immutable |
Vary |
Accept (the negotiated format depends on the request header) |
X-Media-Transform |
w{width}h{height}q{quality}.{format} — present only on real variant responses |
Cache key — {tenantId}/{hash}/variants/t{height}q{quality}-{width}.{format} (e.g. global/9f2c…/variants/t0q82-320.webp). It is derived from the original’s tenant-scoped path and content hash, ordered before any transform work, so the tenant + signature gates always run first.
Never transformed (original streamed instead, logged at debug): SVG, GIF, animated WebP/AVIF, TIFF/BMP/HEIC/PDF/video, sources above 40 MB, non-canonical paths, unencodable fmt, and any encoder/disk failure. Cloud backends (s3, r2, cloudinary) keep their 302 redirect to the CDN public URL and never pull bytes through Node for a transform.
Permissions: none beyond the route’s existing access rules — public/global assets stay public, tenant assets still require the matching tenantId, and MEDIA_SIGNED_URL_ENABLED still requires sig/exp (the signature is not part of the variant key).
6. Lifecycle — Processing Status, Retention & Cleanup
metadata.processingStatus
Every stored media document carries a derivative pipeline state:
| Value | Meaning |
|---|---|
processing |
The original is safe; deferred renditions are still being written. |
ready |
Every rendition the pipeline produces is available. |
failed |
Derivation failed (metadata.processingError); the original still serves. |
Uploads start as processing for raster images and ready for everything else. The deferred variant job flips the value; POST /api/media/sync-status repairs legacy rows and can re-queue missing work. The gallery exposes a status facet and the details modal renders the same state.
POST /api/media/sync-status
Derives status from actual derivative evidence (metadata.imageVariants / thumbnails) instead of trusting the stored value.
{
"mediaId": "optional — scope to one asset",
"persist": true,
"requeue": false
}
requeue: true schedules variant generation for raster images that are processing/failed and marks them processing. Response: { scanned, ready, processing, failed, updated, queued }.
POST /api/media/cleanup
Reference-aware version retention + orphan blob release, runnable without the job queue (works with DISABLE_JOBS or a stuck worker).
{
"keepVersions": 10,
"mediaId": "optional — scope to one asset",
"dryRun": false
}
Rules:
- The newest
keepVersionshistorical entries (default 10) are retained per asset. - A content hash is released only when no record still references it — as the active original or through any retained version. Shared hashes survive.
- Released hashes delete their variant directory and original blobs together.
dryRun: truereports the plan without mutating the database or storage.
Response: { dryRun, scanned, versionsPruned, hashesReleased, variantSetsDeleted, originalsDeleted, keepVersions }.
7. Direct & Resumable Uploads
Two additional lanes cover the large-file path:
- Presigned direct (S3 / R2) —
POST /api/media/upload-instructionsreturns a SigV4-presignedPUTURL, a tenant-scoped staging key, and an HMAC token. The client uploads bytes directly to the bucket, then callsPOST /api/media/direct-upload/completeto validate size/MIME and ingest. - TUS 1.0.0 —
POST /api/media/tuscreates a resumable session;HEAD/PATCH/DELETE /api/media/tus/{id}inspect, continue, or abort it. Sessions are owner-bound (HMAC token + tenant + user), capped at the API body limit, and expire after 24 hours. The browser client resumes fromlocalStorageafter a reload.
Both lanes reuse MediaService.saveMedia, so dedupe, magic-byte validation, derivatives and tenant scoping are unchanged. Full contract, headers, limits and security model: Direct & Resumable Uploads.
Related Documents
- Media System Architecture — virtual folders, gallery DnD, integrity gate
- Media Sharing Endpoint — share links (unaffected by virtual folder moves)
- Collections Reference (collections.ts)
- System Reference (system.ts) —
system-virtual-foldernamespace