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
Request Flow
- PrepareUpload → Client posts metadata; Service issues a media ID and (for chunked uploads) a chunk plan.
- HandleUpload / HandleChunkUpload → Bytes stream through the Controller into Service, then directly into the configured
storage.Provider(local FS, S3, GCS). - CompleteChunkedUpload → Service stitches the chunks server-side, writes the final object, and updates the
mediarow with content hash + size. - Permission Check → Every read/delete passes through
CheckMediaAccess, which validates ownership or shared-blob access. - 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.
ViewMediastreams with browser-friendlyContent-Typeheaders;DownloadMediaforcesContent-Disposition: attachment.
Service
- Decouples HTTP concerns from storage by routing all bytes through a
storage.Providerinterface (provider_adapter.go). BulkDeleteMediareturns 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 state | What the hero renders |
|---|---|
One or more files flagged is_preview | Exactly those files, in preview_order order, as a row |
| No files flagged | Nothing — title, body, and the files stay in the drawer |
| Flagged files exist but none are renderable | Nothing — an explicit choice is never silently overridden |
blob.show_preview === false | Nothing, 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.AutoStarFirstAttachmentflags 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:
| Value | Result |
|---|---|
top | Horizontal row above the body. The default, and the original behaviour |
bottom | Same row, below the body and above the files dock |
bottom-sticky | The 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 / right | Vertical 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 |
none | No 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-stickytreats 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
MediaService—prepareUpload,upload,uploadChunk,completeChunkedUpload,getById,download,view,update,delete,bulkDelete,uploadWithProgress.Media— canonical wire shape.PrepareUploadRequest,PrepareUploadResponse— generated.FileManager— high-level helper for browser uploads (handles chunking + progress reporting).
Cross-Module Integration
| Module | Relationship |
|---|---|
| Blobs | Media rows reference a blob_id; the Blobs module embeds files: Media[] on responses. |
| Storage | All bytes flow through the storage.Provider selected by the user’s active provider config. |
| Permissions | CheckMediaAccess 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.