Skip to content

Documentation

Media (media.ts)

Reference for standard media management — uploads, intelligent processing, folder structures, and CRUD.

10/19/2026
16 min read Edit on GitHub

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.

Important

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, hasMediaDrag in media-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
Important

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) or mp4; resolutions ⊆ 1080p | 720p | 480p | 360p; bitrate — auto or <n>k.
  • Responds 202 immediately and records progress on the asset: metadata.transcode = { status: processing | ready | failed }. Playback URLs land on metadata.hls.master / metadata.mp4.url.
  • Requires ffmpeg on the server PATH (or FFMPEG_PATH). Without it the endpoint fails closed with 503 FFMPEG_UNAVAILABLE — it never pretends a job started.
  • Videos above 512 MB are rejected with 413 FILE_TOO_LARGE; non-video assets with 400 NOT_A_VIDEO.
  • Only the media:write holder’s tenant is read/written (tenantId scope 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.

sequenceDiagram participant User participant API as Media Handler participant Jobs as Job Queue Service participant Storage as Storage Adapter User->>API: POST /api/media API->>API: Calculate Hash & Deduplicate API->>Storage: Store Original File API->>Jobs: Spawn Processing Job API-->>User: 202 Accepted (OperationID) loop Polling User->>API: GET /api/media/jobs/{id} API-->>User: status: completed end

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, formattedSize
  • byType — top 5 types by storage share
  • insights — actionable cards (large files, old files, dominant types)
  • trends — last 12 months of upload growth
  • quota — used/available bytes, percentage, healthy | warning | critical status

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/fmt were 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 keepVersions historical 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: true reports 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-instructions returns a SigV4-presigned PUT URL, a tenant-scoped staging key, and an HMAC token. The client uploads bytes directly to the bucket, then calls POST /api/media/direct-upload/complete to validate size/MIME and ingest.
  • TUS 1.0.0 — POST /api/media/tus creates 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 from localStorage after 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

apimediauploadimagesdamvirtual-folders
Was this page helpful?