[Playgrounds P5b] Video media pipeline — upload, virus scan, transcode + thumbnail, storage/serving (event-driven) #337

Closed
opened 2026-07-02 15:15:06 +00:00 by spikerj · 2 comments
Owner

Sub-ticket of P5 (#328), epic #323. Depends on #336 (model + endpoints).

What: the upload + processing pipeline for community-submitted lesson videos, reusing the event-driven media pattern from SpikerSoft.EventHandlers.BlogMediaProcessor (RabbitMQ exchange/queue consumers, workflow-state doc, ClamAV scan, move-to-final + static serving).

Scope:

  • Upload endpoint / staging via IFileProcessingService (new Videos:StagingPath), accepted extensions + size/length limits documented
  • Event contract + publisher (lesson.video.lifecycle exchange, lesson.video.received routing key) mirroring BlogMediaEventPublisher
  • Worker consumers: received → scan (ClamAV) → transcode (H.264/AAC MP4 web-safe; ffmpeg) → thumbnail (poster frame) → move-to-final; populate LessonVideo.url + thumbnailUrl + durationSeconds
  • Static serving of the final directory (mirror /blog-pictures/.../lesson-videos/...) in WebApplicationExtensions
  • Workflow-state doc + indexes (mirror BlogMediaWorkflowState)

Note: the current blog pipeline is local-filesystem + ImageSharp (images only, no transcode). Video adds ffmpeg transcode/thumbnail — confirm ffmpeg availability in the worker image or add it. Storage/serving decision (S3/MinIO vs local) to be made here.

Acceptance: a submitted raw video is scanned, transcoded to a web-safe MP4 with a poster thumbnail, and served from the final store; LessonVideo fields are populated; failures land the workflow in a terminal error state without exposing the video.

Sub-ticket of P5 (#328), epic #323. Depends on #336 (model + endpoints). **What:** the upload + processing pipeline for community-submitted lesson videos, reusing the event-driven media pattern from `SpikerSoft.EventHandlers.BlogMediaProcessor` (RabbitMQ exchange/queue consumers, workflow-state doc, ClamAV scan, move-to-final + static serving). **Scope:** - [ ] Upload endpoint / staging via `IFileProcessingService` (new `Videos:StagingPath`), accepted extensions + size/length limits documented - [ ] Event contract + publisher (`lesson.video.lifecycle` exchange, `lesson.video.received` routing key) mirroring `BlogMediaEventPublisher` - [ ] Worker consumers: received → scan (ClamAV) → transcode (H.264/AAC MP4 web-safe; ffmpeg) → thumbnail (poster frame) → move-to-final; populate `LessonVideo.url` + `thumbnailUrl` + `durationSeconds` - [ ] Static serving of the final directory (mirror `/blog-pictures/...` → `/lesson-videos/...`) in `WebApplicationExtensions` - [ ] Workflow-state doc + indexes (mirror `BlogMediaWorkflowState`) **Note:** the current blog pipeline is local-filesystem + ImageSharp (images only, no transcode). Video adds ffmpeg transcode/thumbnail — confirm ffmpeg availability in the worker image or add it. **Storage/serving decision (S3/MinIO vs local) to be made here.** **Acceptance:** a submitted raw video is scanned, transcoded to a web-safe MP4 with a poster thumbnail, and served from the final store; `LessonVideo` fields are populated; failures land the workflow in a terminal error state without exposing the video.
Author
Owner

Architecture decision (storage/serving + transcode) — resolves the open question in the description

Storage: local bind-mount on SERVER, NOT S3/MinIO. Investigation of the swarm confirms all media is host bind-mounted under /mnt/fusionio/spikersoft/... (the enterprise 3TB Fusion SSD on node SERVER) — there is no GlusterFS or object store in any stack (spikersoft-backend/deploy/docker-stack.yml). The API and every media worker (file-movement, upload-coordinator, blog-media-processor, security-scanner, metadata-extractor) are all pinned to node.hostname == SERVER and mount the same fusionio paths; that co-location is how they share files today. GlusterFS (/mnt across DreamStream1–7) is intentionally out of the media topology. Introducing MinIO/S3 would contradict every existing media path for no benefit here.

Design — mirror SpikerSoft.EventHandlers.BlogMediaProcessor:

  • New worker SpikerSoft.EventHandlers.LessonVideoProcessor, pinned to node.hostname == SERVER, mounting:
    • staging /mnt/fusionio/spikersoft/uploads/lesson-videos -> /app/uploads/lesson-videos
    • final /mnt/fusionio/spikersoft/lesson-videos -> /app/lesson-videos
  • API stages the upload via IFileProcessingService (new Videos:StagingPath = /app/uploads/lesson-videos) and publishes lesson.video.received on a new lesson.video.lifecycle topic exchange (mirrors BlogMediaEventPublisher).
  • Worker flow: received → ClamAV scanffmpeg transcodeposter thumbnail (JPG)move-to-final → update LessonVideo.Url / ThumbnailUrl / DurationSeconds and status (stays Pending for the #338 moderation queue). Workflow-state doc mirrors BlogMediaWorkflowState.
  • Transcode output: single normalized MP4 (H.264/AAC, +faststart) + JPG poster — not HLS. Plays via native <video>, which the #338 moderation UI and the #339 playground tab already use. Adequate for short lesson clips; HLS can be revisited if long videos become common.
  • ffmpeg: installed in the worker Dockerfile (apt-get install -y ffmpeg), invoked via System.Diagnostics.Process (no native bindings).
  • Serving: new static route /lesson-videos via PhysicalFileProvider in WebApplicationExtensions, mirroring /blog-pictures.
  • New infra stack spikersoft-infrastructure/spikersoft-lesson-video-processor/docker-stack.yml.

Net effect on the SERVER-fusion vs Gluster split: because the worker is pinned to SERVER alongside the API, it reads the same fusionio SSD the API wrote to; the Gluster pool is never involved (correct — it isn't part of the media topology). RabbitMQ is pinned off SERVER, but that's messaging only, not file IO.

Status: decision recorded; implementation deferred — proceeding with #339 (playground Videos tab) first, then returning to build this pipeline.

### Architecture decision (storage/serving + transcode) — resolves the open question in the description **Storage: local bind-mount on `SERVER`, NOT S3/MinIO.** Investigation of the swarm confirms all media is host bind-mounted under `/mnt/fusionio/spikersoft/...` (the enterprise 3TB Fusion SSD on node `SERVER`) — there is no GlusterFS or object store in any stack (`spikersoft-backend/deploy/docker-stack.yml`). The API and every media worker (`file-movement`, `upload-coordinator`, `blog-media-processor`, `security-scanner`, `metadata-extractor`) are all pinned to `node.hostname == SERVER` and mount the same fusionio paths; that co-location is how they share files today. GlusterFS (`/mnt` across DreamStream1–7) is intentionally out of the media topology. Introducing MinIO/S3 would contradict every existing media path for no benefit here. **Design — mirror `SpikerSoft.EventHandlers.BlogMediaProcessor`:** - New worker `SpikerSoft.EventHandlers.LessonVideoProcessor`, pinned to `node.hostname == SERVER`, mounting: - staging `/mnt/fusionio/spikersoft/uploads/lesson-videos -> /app/uploads/lesson-videos` - final `/mnt/fusionio/spikersoft/lesson-videos -> /app/lesson-videos` - API stages the upload via `IFileProcessingService` (new `Videos:StagingPath = /app/uploads/lesson-videos`) and publishes `lesson.video.received` on a new `lesson.video.lifecycle` topic exchange (mirrors `BlogMediaEventPublisher`). - Worker flow: received → **ClamAV scan** → **ffmpeg transcode** → **poster thumbnail (JPG)** → **move-to-final** → update `LessonVideo.Url` / `ThumbnailUrl` / `DurationSeconds` and status (stays `Pending` for the #338 moderation queue). Workflow-state doc mirrors `BlogMediaWorkflowState`. - **Transcode output: single normalized MP4 (H.264/AAC, `+faststart`)** + JPG poster — not HLS. Plays via native `<video>`, which the #338 moderation UI and the #339 playground tab already use. Adequate for short lesson clips; HLS can be revisited if long videos become common. - **ffmpeg**: installed in the worker Dockerfile (`apt-get install -y ffmpeg`), invoked via `System.Diagnostics.Process` (no native bindings). - **Serving**: new static route `/lesson-videos` via `PhysicalFileProvider` in `WebApplicationExtensions`, mirroring `/blog-pictures`. - New infra stack `spikersoft-infrastructure/spikersoft-lesson-video-processor/docker-stack.yml`. **Net effect on the SERVER-fusion vs Gluster split:** because the worker is pinned to `SERVER` alongside the API, it reads the same fusionio SSD the API wrote to; the Gluster pool is never involved (correct — it isn't part of the media topology). RabbitMQ is pinned off `SERVER`, but that's messaging only, not file IO. Status: **decision recorded; implementation deferred** — proceeding with #339 (playground Videos tab) first, then returning to build this pipeline.
Author
Owner

Done — merged.

Delivered against scope:

  • Upload endpoint POST /api/Lessons/{n}/videos/upload + staging via new LessonVideoStagingService (Videos:StagingPath; .mp4/.webm/.mov/.m4v, 200 MB cap).
  • Event contract + publisher (lesson.video.lifecycle exchange, lesson.video.received routing key) mirroring BlogMediaEventPublisher.
  • Worker SpikerSoft.EventHandlers.LessonVideoProcessor: received → scan (ClamAV) → transcode (ffmpeg H.264/AAC MP4 +faststart) → poster frame → move; populates url/thumbnailUrl/durationSeconds. A failed scan rejects the video; other failures land the workflow in a terminal error state without exposing the video.
  • Static serving of /lesson-videos/... in WebApplicationExtensions.
  • LessonVideoWorkflowState doc + indexes (lesson-video-workflows), mirroring BlogMediaWorkflowState.

Storage/serving decision: kept local-filesystem host bind mounts on SERVER (consistent with the blog pipeline) rather than introducing S3/MinIO. ffmpeg is bundled into the worker runtime image.

Follow-up (not blocking): the SPA still submits videos by URL (P5d); wiring the in-browser file-upload flow to /videos/upload is a small frontend follow-up if desired.

Done — merged. - Backend: spikerj/spikersoft-backend#58 - Infra: spikerj/spikersoft-infrastructure#4 **Delivered against scope:** - ✅ Upload endpoint `POST /api/Lessons/{n}/videos/upload` + staging via new `LessonVideoStagingService` (`Videos:StagingPath`; `.mp4/.webm/.mov/.m4v`, 200 MB cap). - ✅ Event contract + publisher (`lesson.video.lifecycle` exchange, `lesson.video.received` routing key) mirroring `BlogMediaEventPublisher`. - ✅ Worker `SpikerSoft.EventHandlers.LessonVideoProcessor`: received → scan (ClamAV) → transcode (ffmpeg H.264/AAC MP4 `+faststart`) → poster frame → move; populates `url`/`thumbnailUrl`/`durationSeconds`. A failed scan rejects the video; other failures land the workflow in a terminal error state without exposing the video. - ✅ Static serving of `/lesson-videos/...` in `WebApplicationExtensions`. - ✅ `LessonVideoWorkflowState` doc + indexes (`lesson-video-workflows`), mirroring `BlogMediaWorkflowState`. **Storage/serving decision:** kept local-filesystem host bind mounts on `SERVER` (consistent with the blog pipeline) rather than introducing S3/MinIO. ffmpeg is bundled into the worker runtime image. **Follow-up (not blocking):** the SPA still submits videos by URL (P5d); wiring the in-browser file-upload flow to `/videos/upload` is a small frontend follow-up if desired.
Sign in to join this conversation.