Skip to content

YourBOX Video Storage

ADR-0005 commits YourBOX to a Flysystem abstracted storage layer with MinIO in the homelab and on the VPS, Azure Blob in Azure, and a local adapter for workstation development. This page documents the key layout, the upload and transcoding pipeline, the signed URL strategy and the backup model.

The Symfony service App\Catalog\Application\Port\VideoStorage exposes the methods upload, download, delete, getSignedReadUrl and exists. One Flysystem adapter implements the port. Selection is driven by STORAGE_DSN parsing.

Adapter Used in DSN scheme Notes
s3 Homelab, VPS s3://... MinIO single node on VPS, distributed in homelab
azure_blob Azure azure-blob://... Geo redundant storage class
local Local docker compose file://... Host volume mount, never used outside dev

The DSN is parsed by App\Catalog\Infrastructure\Storage\StorageFactory which builds the matching Flysystem filesystem and wraps it in a FlysystemVideoStorage adapter.

The database stores a logical storage key, not a filesystem path. The format is:

videos/<YYYY>/<MM>/<ulid>/source.<ext>
videos/<YYYY>/<MM>/<ulid>/hls/index.m3u8
videos/<YYYY>/<MM>/<ulid>/hls/segment_<NNN>.ts
videos/<YYYY>/<MM>/<ulid>/thumbnail.jpg
videos/<YYYY>/<MM>/<ulid>/preview.webp

The ULID encodes the upload timestamp, so even without the YYYY/MM prefix the keys remain lexicographically sortable by upload time. The YYYY/MM prefix groups objects by upload month to keep object listings cheap on the operator side.

A videos/orphans/ prefix collects keys that failed transcoding. The reconciliation job documented on the runbook page sweeps this prefix daily.

sequenceDiagram
participant Browser
participant Controller as UploadController
participant Storage as VideoStorage adapter
participant DB as PostgreSQL
participant Bus as Messenger bus
participant Worker as Transcoding worker
Browser->>Controller: POST /catalog/upload (multipart, CSRF token)
Controller->>Controller: validate MIME, size, extension
Controller->>Storage: upload(source key, stream)
Controller->>DB: INSERT video (status=pending, source_key)
Controller->>Bus: TranscodeVideo(video_id)
Controller-->>Browser: 202 with status URL
Bus->>Worker: dispatch
Worker->>Storage: download(source key) into tmpfs
Worker->>Worker: ffmpeg transcode (HLS 360p, 540p, 720p)
Worker->>Storage: upload manifest and segments under hls/ prefix
Worker->>Storage: upload thumbnail.jpg and preview.webp
Worker->>DB: UPDATE video (status=published, manifest_key, duration_seconds)
Worker->>Bus: VideoPublished(video_id)

Until the video reaches status=published, the catalog hides it from public listings. A separate admin view shows pending videos so moderators can intervene if the transcoding stalls.

The FFmpeg pipeline produces an HLS manifest with three renditions and a thumbnail sequence. The settings live in App\Catalog\Infrastructure\Transcoding\HlsProfile.

Rendition Resolution Video bitrate Audio bitrate Keyframe interval
360p 640x360 800 kbps 96 kbps 2 seconds
540p 960x540 1500 kbps 128 kbps 2 seconds
720p 1280x720 3000 kbps 128 kbps 2 seconds

The thumbnail is captured at the 10 percent mark of the video, then resized to 1280x720 JPEG. A 5 second WebP preview is generated by sampling 10 frames spread across the video.

Public videos serve their manifest through a signed URL valid for one hour. The browser fetches the manifest, then fetches segments with their own signed URLs included in the manifest by the adapter rewriter.

For MinIO, the adapter signs S3 presigned URLs with the storage credentials. For Azure Blob, the adapter generates SAS tokens with read permission on the manifest and segment blobs.

The getSignedReadUrl method on the storage port carries a ttl parameter. The catalog request handler uses one hour for public videos, ten minutes for private videos, and a single use signature for download endpoints (when added).

$manifestUrl = $this->storage->getSignedReadUrl(
$video->manifestKey(),
ttl: new \DateInterval('PT1H'),
);

Traefik terminates HTTPS in front of the application on the VPS and in the homelab. Traefik does not proxy the segments; the browser hits the storage backend directly. This keeps the application out of the playback bandwidth path and lets the storage backend handle range requests natively.

In Azure, the same pattern applies through Azure Front Door or the App Service URL. The Azure Blob container exposes the segments through HTTPS with the SAS token.

Backup ownership is delegated to the storage backend:

  • MinIO in homelab: object replication to a secondary MinIO cluster on the trusted admin plane plus object versioning enabled for 30 days.
  • MinIO on VPS smoke test: object versioning for 7 days. Recovery from a catastrophic VPS loss falls back to the homelab cluster acting as the cold copy.
  • Azure Blob: geo redundant storage with read access geo replication. Soft delete retention is set to 30 days.
  • Local adapter: no backup, by definition of “workstation development”. The compose stack ships a make target make storage:flush to clean up.

The runbook import-mysql-to-postgres documents the historical migration from the 2020 absolute path metadata to the new key layout. The runbook restore-video documents per backend recovery for a single video.

  • Uploads are restricted to authenticated users with ROLE_USER. The upload endpoint enforces CSRF, validates the MIME type against image/jpeg (thumbnail) and video/mp4|webm|quicktime|x-matroska (source), checks the magic bytes via finfo_file, and caps the size at 2 GiB.
  • The Symfony controller never trusts the client provided filename. The storage key is generated server side and the client name is stored as a separate display metadata field.
  • The MinIO root credentials live in OpenBao under kv/yourbox/minio and never touch the application image. The application uses a scoped IAM policy that grants s3:PutObject, s3:GetObject and s3:DeleteObject on arn:aws:s3:::yourbox/* only.
  • Range request bypass attacks are mitigated by the storage backend signature validation. A signed URL is bound to a specific key prefix.