Skip to content

Media Module

The Media module owns the lifecycle of binary file attachments — uploads (single-shot and chunked), downloads, streaming, and deletes — on top of the pluggable Storage backends. A Media record stores metadata (MIME, size, hash, owner, optional blob_id association) plus a storage key that points at the actual bytes in whatever backend the user has configured (local FS, S3, GCS, Dropbox, Drive). The module never holds bytes — it streams them through.

Class Diagram

uses

uses

caches

queries

Controller

-service: Service

-logger: Logger

+PrepareUpload(w, r)

+HandleUpload(w, r)

+GetMedia(w, r)

+DownloadMedia(w, r)

+ViewMedia(w, r)

+DeleteMedia(w, r)

+BulkDeleteMedia(w, r)

+HandleChunkUpload(w, r)

+CompleteChunkedUpload(w, r)

Service

-repo: Repository

-cache: Cache

-logger: Logger

+PrepareUpload()

+HandleUpload()

+GetMedia()

+CheckMediaAccess()

+DownloadMedia()

Repository

-db: Database

+Query()

+Insert()

+Update()

+Delete()

Cache

Database

Request Flow

  1. PrepareUpload → Client posts metadata; Service issues a media ID and (for chunked uploads) a chunk plan.
  2. HandleUpload / HandleChunkUpload → Bytes stream through the Controller into Service, then directly into the configured storage.Provider (local FS, S3, GCS).
  3. CompleteChunkedUpload → Service stitches the chunks server-side, writes the final object, and updates the media row with content hash + size.
  4. Permission Check → Every read/delete passes through CheckMediaAccess, which validates ownership or shared-blob access.
  5. Event Emission → Successful uploads/deletes emit wire events for SDK subscribers.

Key Components

Controller

  • Accepts both single-shot multipart uploads and chunked uploads via separate endpoints.
  • ViewMedia streams with browser-friendly Content-Type headers; DownloadMedia forces Content-Disposition: attachment.

Service

  • Decouples HTTP concerns from storage by routing all bytes through a storage.Provider interface (provider_adapter.go).
  • BulkDeleteMedia returns per-item results so partial failures are surfaced to the caller.
  • Emits typed events (events.go) consumed over the wire-event channel by SDK clients.

Repository

Stores media rows: ID, owner, blob association, MIME, size, hash, and a storage-key reference to the underlying provider object.

Special Behaviors

Two upload paths share one record

Both single-shot and chunked uploads resolve into the same media row. PrepareUpload always issues an ID first; small uploads then POST directly to HandleUpload with that ID, large uploads stream chunks to HandleChunkUpload and finalize with CompleteChunkedUpload. The DB row is identical in either case, so the rest of the platform doesn’t care which path was used.

Cleanup on blob delete

The Service subscribes to blob.deleted events on the in-process events.EventBus. When a blob is soft-deleted, attached media are not immediately purged — the Storage module schedules cleanup that respects the delete_batch_id so a blob restore (see Blobs) reattaches the bytes correctly.

Hash-based dedup hooks

The Service computes a SHA-256 of the body during ingestion and writes it to the media.content_hash column. There’s no automatic dedup yet, but the index is in place so a future “reuse existing media if the hash matches” optimization can land without a migration.

Preview membership (is_preview / preview_order)

Any number of a blob’s media rows can be flagged is_preview. The flagged set is what the client renders as the blob’s hero row above its body, ordered by preview_order ascending with created_at breaking ties. Both fields are writable via PATCH /media/{id}, alongside original_filename — everything else on the record is owned by the upload pipeline and rejected.

Selection on the client is the flagged set and nothing else — there is deliberately no implicit fallback:

Blob stateWhat the hero renders
One or more files flagged is_previewExactly those files, in preview_order order, as a row
No files flaggedNothing — title, body, and the files stay in the drawer
Flagged files exist but none are renderableNothing — an explicit choice is never silently overridden
blob.show_preview === falseNothing, regardless of flags

“Renderable” means image/*, video/*, audio/*, or a 3D-model file extension (stl/glb/gltf/obj/fbx/usdz/3mf/ply). Flagging a .zip is accepted by the API but has no visual effect.

An earlier revision fell back to “first renderable attachment” when nothing was flagged. That made un-flagging every file a no-op — the hero simply came back — so the fallback was removed. Two mechanisms keep the no-fallback rule from leaving blobs blank:

  • Migration 42 backfilled a flag onto whichever file the old rule would have picked, so no existing blob changed appearance on deploy.
  • Repository.AutoStarFirstAttachment flags a renderable upload when it is the first file its blob has ever had. Once a blob holds more than one file the predicate can never fire again, so un-flagging everything is sticky.

Preview placement (blobs.preview_layout / preview_size)

Where the flagged set renders is a blob setting, not a media one — see migration 43. preview_layout is one of:

ValueResult
topHorizontal row above the body. The default, and the original behaviour
bottomSame row, below the body and above the files dock
bottom-stickyThe row pinned (position: sticky) to the bottom of the visible view — the page scroller or the modal body. The files dock pins itself directly above it, and the body scrolls behind both. Migration 48
left / rightVertical column beside the body, which takes the remaining width. Pinned under the sticky header and capped to the visible height; a long preview list scrolls inside the column instead of lengthening the page
noneNo strip anywhere; the files stay reachable in the dock

preview_size (10–75, default 25) is how much room the strip gets. It behaves differently per axis, deliberately:

  • Row layouts treat it as a maximum height, viewport-relative (Nvh). A percentage would not resolve — on the full blob page the column is auto-height, so there is nothing to take a percentage of.
  • bottom-sticky treats it as a share of the visible band — the height between the sticky blob header and the bottom of the scroller, measured at runtime (utils/stickyFit.ts), with a 120px floor.
  • Column layouts treat it as a fixed share of width. The parent’s width is always definite there, and a side column needs a predictable split for the body to fill the remainder.

show_preview = false still suppresses the strip regardless of layout; the two are not redundant, because show_preview also governs whether the dashboard card uses its specialised type-hint renderer. Migration 43 backfills preview_layout = 'none' onto rows that already had show_preview = false so the pair cannot disagree on existing data.

Both fields are edited from the Preview section of Blob Settings, and both surfaces (full page and dashboard pop-up) read them through the same resolvePreviewSlot helper, so they cannot drift apart.

SDK Surface

Cross-Module Integration

ModuleRelationship
BlobsMedia rows reference a blob_id; the Blobs module embeds files: Media[] on responses.
StorageAll bytes flow through the storage.Provider selected by the user’s active provider config.
PermissionsCheckMediaAccess either validates ownership directly or defers to the parent blob’s permission set.

API Reference

See the interactive API reference /media tag for live examples, including the chunked-upload sequence.