One trip through the whole system¶
The fastest way to understand this codebase is to follow a single action all the way down. This is what happens between somebody tapping Publicar and their friends being able to watch, touching seven layers on the way. Terms in bold are in the glossary.
Everything before this point — the draw, the recording, the edit — is covered where it happens below.
0. Before the button exists¶
By the time anyone can publish, three things have already happened.
The draw. A scheduler tick found a circle whose turn had ended, or one that
had never had a turn at all, and picked a vlogger
(app/services/turns.py::tick → app/services/draw.py). The weighting favours
whoever has had fewest turns and excludes the previous vlogger unless they are
the only candidate. The circle gets a push notification.
The recording. app/record/[turnId].tsx. Each take is written to the phone's
sandbox and appended to the manifest (src/storage/clipVault.ts). Nothing is
uploaded and nothing touches the server, so somebody can record on Monday and
finish on Wednesday. Flipping the camera closes one segment and opens
another inside the same take.
The edit. src/edit/ builds an EDL from the manifest, chooses an
engine (engine.ts), and renders. On Android that is the native module:
decode → OpenGL → re-encode, one encoder for the whole timeline. The result is
one mp4 in the app's sandbox, and the review screen plays it.
1. The tap¶
app/review/[turnId].tsx → publishVlog() in src/edit/publish.ts.
The publish button deliberately sits above the editing controls. The premise of the product is that nobody wants to edit, so the default path has to be the shortest one; reordering and deleting are there for when something is wrong, not as a step everyone walks through.
2. Asking permission to upload¶
POST /turns/{id}/upload-url → app/api/turns.py.
The server checks the caller is the vlogger and the turn is still open, works
out the object key (circles/{circle}/vlogs/{turn}.mp4) and returns a
presigned URL — a signed permit that expires. It does not accept the file.
This is the hinge of the whole architecture. If the video went through the API, the machine would need to be big enough for everybody watching a premiere at once, which is the one moment they all arrive together. Instead the server authorises a transfer it never carries. See the diagram in architecture.md.
3. The upload¶
Back in publish.ts: a plain HTTP PUT from the phone straight to Cloudflare
R2, with the headers the server said to send and nothing else.
Retried with exponential backoff, because this is a phone on mobile data. The retry is safe because the object key is derived from the turn, so a second attempt overwrites the first rather than leaving debris.
Almost everything that ever goes wrong with R2 goes wrong in the gap between signing and uploading, because the signature covers exactly the headers the client sends.
scripts/r2_check.pyexists to cross that gap on purpose.
4. Telling the server it worked¶
POST /turns/{id}/complete.
The server confirms the object really exists in the bucket — it does not take
the client's word for it — creates the Vlog row, moves the turn to
published, and sets the premiere to end 24 hours later.
Idempotent, deliberately. A phone that uploads, loses signal before the reply and retries must not create a second vlog or restart the premiere.
5. Everybody else finds out¶
The push goes to every member of the circle except the vlogger
(app/services/push.py). Sending is fire-and-forget: a notification that fails
must never stop a vlog from publishing.
6. Watching¶
app/watch/[turnId].tsx asks the API for the turn, which includes a
playback_url. With a CDN domain configured that is a plain public URL and the
video is served from Cloudflare's edge — the API machine is not involved at all.
Without one it is signed against the bucket instead.
POST /vlogs/{id}/view records that somebody watched, which is what the "who
has seen it" count and the unanimous early-end vote are built on.
7. And round again¶
24 hours later a scheduler tick finds the premiere has expired, archives the
turn and draws the next vlogger. The same tick() also hands out strikes for
deadlines that passed with nothing recorded.
tick() is idempotent and runs every 15 minutes in production. Both facts
matter: idempotent because it must survive being run twice, and 15 minutes
because the database sleeps between ticks — see limits.md.
What to read next¶
- gotchas.md — the things that will confuse you, and cost hours if nobody tells you first.
- architecture.md — why the pieces are arranged this way.
- decisions.md — why they are not arranged some other way.