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.
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.
From the admin wizard to a live title
The browser uploads straight to Cloud Storage; the worker is created only while there is work.
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
- Video info — title, description, cast, series/season placement.
- Artwork — two required crops: portrait 3:4 (mobile) and landscape 16:9 (web).
- Videos — trailer and feature. Both are required.
- 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
| Call | Effect |
|---|---|
POST /videos/addVideo | Creates a UUID and staging/{fileId} with a seven-day expiresAt. |
POST /videos/signUpload | Returns 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/completeUpload | Verifies the object, pins its generation. For images, converts to WebP synchronously and records the result. |
POST /videos/saveVideo | Verifies 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:
| Answer | Meaning | What to do |
|---|---|---|
200 | Converted, or a retry of an already-converted object with the same generation | Continue |
409 | The source changed during conversion | Re-crop and upload |
422 | Conversion failed; staging holds the error | Re-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:
- Verifies every source object exists in the expected bucket.
- Creates
videos/{id}if it does not exist, includingcast[](which also feedssearchTokens). - Writes
flags/shutdownCancelso an idle VM is not deleted mid-enqueue. - 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:
- Runs
gstv-infra/startup-script.sh, which asserts metadata, environment identity, bucket names, the baked Firebase key, the RTDB host and the timers. - Pulls the worker image.
- Mounts input and output buckets with gcsfuse.
- Starts the container.
The worker then:
- claims jobs with
child_addedonqueue/transcode; - processes strictly one job at a time — trailer first, then feature;
- transcodes to the HLS ladder, writing the master
playlist.m3u8before the renditions; - writes one
queue/resolutionEventschild 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 afailureReason; - writes
notificationsof typevideo.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:
- The admin API build then in production still queued artwork on
queue/imageJobs. - The deployed worker image was older than the image lane and had no listener for that queue.
- The create gate counted the queue as work; the delete gate required it to be empty.
- 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:
- 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.
- 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.
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:
- Marks
deletion.state = "deleting"and clearsisReady. - Sweeps RTDB queue entries (including the legacy artwork paths and
vpsStatus/currentImageJob). - Sweeps GCS input, output and thumbnail prefixes.
- Sweeps Firestore references: engagement subcollections, playlist entries, feedback.
- Removes staging.
- Deletes the video document and decrements series/season counters.
- Writes a
video.deleteaudit 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.