Skip to main content

Upload to publish

A title exists in two worlds at once: GCS objects (the film, the artwork) and a Firestore document (the metadata). Publishing means joining them, and the platform only calls a title live when every part is present.

Prefer to see it move?

Watch a video go live animates this same path as a clickable 3D scene, step by step, from the wizard to the worker deleting its own VM.

Flow · ingest

From the admin wizard to a live title

The browser uploads straight to Cloud Storage; the worker is created only while there is work.

Rendering Mermaid diagram…
Zoom 100 percent
Mermaid

The same system, laid out automatically. Use the controls to zoom, open it full screen, or download it for a deck.

The wizard is four steps​

  1. Video info — title, description, cast, series/season placement.
  2. Artwork — two required crops: portrait 3:4 (mobile) and landscape 16:9 (web).
  3. Videos — trailer and feature. Both are required.
  4. Upload — the browser sends everything, then saves.

The wizard no longer waits for processing. It says the admin will be notified in the header bar, and the notification bell reports the outcome. That is why the notification feed is a correctness feature, not a convenience: without it a failed upload is a title that silently never appears.

What each call does​

CallEffect
POST /videos/addVideoCreates a UUID and staging/{fileId} with a seven-day expiresAt.
POST /videos/signUploadReturns a V4 signed PUT (images) or a resumable session URI (video, trailer), with content-type and size limits. Records the extension in staging.
(browser → GCS)Direct upload in 8 MiB chunks, resyncing on 308.
POST /videos/completeUploadVerifies the object, pins its generation. For images, converts to WebP synchronously and records the result.
POST /videos/saveVideoVerifies every object, creates the videos/{id} document if absent, and pushes the transcode job.

Upload order matters: thumbnail, thumbnailPreview, trailer, video, then saveVideo. The server verifies each object before enqueueing, so a save before the last upload fails fast.

Artwork is finished when the response returns​

An image completeUpload is not a notification that work was queued — by the time it answers, the artwork is final:

  • sharp converts to WebP at quality 82, effort 6;
  • the output lands in the images bucket at <prefix>/<fileId>.webp, the same key shape the old worker used, so existing URLs and lifecycle rules still apply;
  • the source object is deleted after the WebP exists;
  • staging records {status:"completed", generation, width, height, bytes, contentType}.

Failure modes are explicit:

AnswerMeaningWhat to do
200Converted, or a retry of an already-converted object with the same generationContinue
409The source changed during conversionRe-crop and upload
422Conversion failed; staging holds the errorRe-upload; check dimensions

Dimensions must match the crop for the kind within a 1.5% aspect tolerance. The server contract is gstv-admin-api/src/utils/imageArtwork.ts; the client mirror is gstv-admin-web/src/config/artwork.js. Change both or neither — the values are not environment-configurable.

What saveVideo starts​

saveVideo does four things in order:

  1. Verifies every source object exists in the expected bucket.
  2. Creates videos/{id} if it does not exist, including cast[] (which also feeds searchTokens).
  3. Writes flags/shutdownCancel so an idle VM is not deleted mid-enqueue.
  4. Pushes a self-contained job to RTDB queue/transcode/{pushId}: {videoId, fileExtension:{video, trailer?}, queuedAt}.

If staging still holds unconverted artwork from the old asynchronous lane, it runs completePendingArtwork first, converting from the source objects still in the images bucket.

The worker​

The admin API's scheduler ensures exactly one fixed-name VM (gstv-vps-<env>-worker) exists while either queue/transcode or the legacy queue/imageJobs is non-empty. It creates and deletes; it never stops and starts.

On boot the VM:

  1. Runs gstv-infra/startup-script.sh, which asserts metadata, environment identity, bucket names, the baked Firebase key, the RTDB host and the timers.
  2. Pulls the worker image.
  3. Mounts input and output buckets with gcsfuse.
  4. Starts the container.

The worker then:

  • claims jobs with child_added on queue/transcode;
  • processes strictly one job at a time — trailer first, then feature;
  • transcodes to the HLS ladder, writing the master playlist.m3u8 before the renditions;
  • writes one queue/resolutionEvents child per finished rendition;
  • deletes the source masters from the input bucket;
  • removes the queue child;
  • when idle for five minutes, or after a 24-hour hard cap, deletes its own VM.

The ladder runs 144p through 2160p; only rungs at or below the source height are produced.

When does a title go live?​

Never because a transcode finished. evaluateVideoReadiness runs a Firestore transaction that requires all three:

  • transcode.status === "completed";
  • a non-empty trailerResolution;
  • thumbnail.thumbnailUrl.

Only that transaction removes staging, sets isReady:true and readinessState:"live", and writes a notifications document of type video.live. The RTDB child is removed only after success — which is what makes a crash safe to retry.

The customer API's catalogue snapshot picks the change up automatically through its onSnapshot listener. No cache invalidation is needed.

When it fails​

transcodeFailedConsumer calls markVideoFailed, which:

  • records readinessState:"failed" and a failureReason;
  • writes notifications of type video.failed;
  • keeps an already-live title live if the failure was a re-transcode, and says so in the message;
  • is guarded by the previous state, because dead letters can be re-delivered.

Why the VM loop happened, and what changed​

On 2026-10-21 production created 212 worker VMs in 24 hours. The chain:

  1. The admin API build then in production still queued artwork on queue/imageJobs.
  2. The deployed worker image was older than the image lane and had no listener for that queue.
  3. The create gate counted the queue as work; the delete gate required it to be empty.
  4. Every 60-second reconcile created a VM that could never finish or be removed.

The fix was structural: artwork now converts at ingest, so nothing in the upload path writes queue/imageJobs. The two orphaned children were removed after a guard confirmed exactly those keys and one fileId.

Two rules came out of it and still hold:

  1. Deploy the consumer first. When a change touches the worker and the API, deploy the worker image first. A producer running ahead of its consumer is an infinite work signal, not a stuck job.
  2. A dead queue is not a stuck job. The create gate needs an age or attempt escape hatch; the deployed image must be able to drain everything the API can write.
Consumer before producer

A producer running ahead of its consumer writes work that nothing can claim. The VM scheduler counts a non-empty queue as work, so it keeps creating machines that can never finish or be removed. Deploy the worker image first, every time a queue contract changes.

The deletion cascade​

Deletion mirrors publishing. GET /videos/:id/deletionPreview shows what will be removed; DELETE /videos/:id then:

  1. Marks deletion.state = "deleting" and clears isReady.
  2. Sweeps RTDB queue entries (including the legacy artwork paths and vpsStatus/currentImageJob).
  3. Sweeps GCS input, output and thumbnail prefixes.
  4. Sweeps Firestore references: engagement subcollections, playlist entries, feedback.
  5. Removes staging.
  6. Deletes the video document and decrements series/season counters.
  7. Writes a video.delete audit log.

It returns 409 while a transcode is in flight or another deletion marker is younger than ten minutes, and 502 listing failed phases otherwise. Every phase is idempotent, so a retry after a partial failure is safe.

Next: Playback — how the result reaches a screen.